From 782306bb8d21cef8be694e7593e41ea564f7da62 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 25 Sep 2026 19:21:51 -0700 Subject: [PATCH 1/3] Re-vendor AGENTS.md and GOVERNANCE.md Verbatim Sections From the Hub Hub main 45669468. Replaced only the declared verbatim regions via carry.py apply-sections. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 32 ++++++++-------- GOVERNANCE.md | 100 ++++++++++++++++++++++++++++---------------------- 2 files changed, 73 insertions(+), 59 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7d8e362..6259007 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,19 +24,20 @@ flowchart TD - **No repository yet, or a local tree with no remote.** Follow the hub's `STANDUP.md` from section 0. That file is hub-only and deliberately not carried, because a repository needing it cannot be relied on to hold a current copy. Note that nothing in it creates the GitHub repository, which is an outward-facing write requiring explicit permission, so section 0A is the list handed to the maintainer before anything else starts. - **A repository with no carried instruction set, or a partial one.** Carry the baseline per the hub's `STANDUP.md` sections 1A and 2, which resolve what this repository is owed from its declared types and workflow model. Absent files are not drift to re-vendor, they are a baseline that never arrived, and the two are fixed differently. - **A repository with the instruction set, current or stale.** Follow the hub's `RESYNC.md`, which runs `AUDIT.md` end to end for the findings and then applies each one in an order that matters, since the rules govern what comes after them, a deletion must precede the re-vendor that would otherwise refresh the file, and only some findings are mechanically detectable at all. An audit that reports drift and stops is half the procedure. -- **A repository that believes it is conformant.** Run the audit anyway and commit the report, because conformance asserted without a report is conformance nobody can check. This is the same procedure as the case above and is listed separately only because it is the one most often skipped. +- **A repository that believes it is conformant.** Run the audit anyway, because conformance asserted without a report is conformance nobody can check. The hub commits that report under its own `reports/`, since a report written by the repository it measures is a claim rather than evidence, so a session in the repository being audited fixes its own drift in its own repository, files its findings about the hub as issues, and leaves the report to a hub-side audit rather than opening a hub pull request to write its own. This is the same procedure as the case above and is listed separately only because it is the one most often skipped. Three rules bound every path above. **Read the hub's `main` branch as ground truth**, since that is the promoted and gated state, and read `develop` only to detect divergence. **Reach the hub as a checkout of your own and fetch it immediately before reading it**, because a clone is whatever it last fetched rather than the branch it names, and work only in that checkout rather than in one that another task is using, per [`GOVERNANCE.md`](./GOVERNANCE.md) "Repository Boundaries and Write Safety" and "Hub-Hosted Tooling". And **the audit is read-only**: it produces a report and never edits the repository it measures, so a fix is a separate, reviewable change. ## Context and Delegation Discipline -An agent session is billed on the context it carries, not the work it does. Every request re-reads the whole accumulated context, so a token added early is paid for again on every request that follows, and a long session bills its last task for every earlier one. These are cost rules. None of them licenses doing less work, skipping verification, or shipping something unreviewed. +An agent session is billed on the context it carries, not the work it does. Every request re-reads the whole accumulated context, so a token added early is paid for again on every request after it. A long session therefore bills its last task for every earlier one. Most of these are cost rules. None of these rules licenses doing less work, skipping verification, or shipping something unreviewed. ### Session Scope - **One deliverable, one session.** A session covers one branch and one deliverable, and ends when that work merges. A multi-step task is one deliverable and stays in one session. Two unrelated tasks are two sessions even when they run back to back. - **End a session at any of these, without being asked:** the branch changes, the pull request merges, or the next task is unrelated to the last. A review round is none of them. A loop still producing findings is the deliverable in progress, and a round count is not a reason to leave one open. -- **Hand off in a file, never in context.** Close a session by writing at most 2 KB to a scratch file: branch, pull request link, what is done, the next command. A summary held in context is re-billed until the session ends, and a summary on disk is read once by whoever needs it. +- **A session orchestrating dispatched work is an exception, and a narrow one.** Its deliverable is the run rather than any branch, so it spans many branches and many merges by construction, and ending it at the first dispatched merge would end the run. The triggers above land on each dispatched task instead, one branch and one deliverable each, which is this rule applied rather than waived. What keeps the exception narrow is that such a session holds no branch of its own and authors none of the work it dispatches, so the file context every other session accumulates is context it never takes on, and it re-derives each round's state from live sources rather than holding it, per "Re-derive state, do not carry it" below. A session that starts editing the files a dispatched task would have edited is an ordinary one again and ends on the triggers above. +- **Hand off in an issue, never in a scratch file and never in context.** Close a session by filing the next link in the handoff chain of the repository holding the work the next session resumes, which for a session that stayed in one repository is this one, and a session that spanned several names the others in the handoff's state section. A track is a lane of work named by a short slug, `default` where a session names none, and a track in use holds exactly one open issue carrying the `handoff` label. The track and the predecessor are recorded in the issue body rather than in its title, so a retitled or hand-edited issue still chains and a reader can tell which lane an open issue belongs to. The new link names its predecessor that way, a forward-link comment then goes onto that predecessor, and the predecessor is closed last, in that order, so a failure part way leaves a discoverable new issue rather than a closed chain with no successor. Each of those three is an outward-facing write, so the write-safety rules in `GOVERNANCE.md` "Repository Boundaries and Write Safety" bind all three exactly as they bind any other write, the identifier rule most of all, since the link a comment and a close target is read live in the same run rather than remembered. The chain needs the `handoff` label to be findable at all, 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 a handoff writes. A session that cannot file a link reports that it could not hand off and leaves the previous link open, whether it is stopped by a repository carrying no such label, by a write it may not make, or by anything else. That is the one alternative this rule allows to a track still in use, and it is a report rather than a file, because a report says the round's record is missing while a file claims to be it. A track whose work is complete is closed out instead, its last link carrying the outcome as a comment and closed with no successor, which leaves the track no longer in use rather than breaking its chain. A scratch file fails three ways the chain closes. It is not found where the next session looks. More than one candidate is found and nothing says which is current. And it holds no history, so a later round re-runs a path an earlier round already tried and already wrote down, which is the one thing a handoff exists to prevent. The handoff carries the next steps in priority order, the external blockers and internal dependencies among them, the state a resume re-reads rather than trusts, listed so the resume knows what to re-read, the account of parked decisions that `GOVERNANCE.md` "Communicating with the User" requires, what the last round did, what not to repeat, and what was learned. That section states the account whole, and it requires the session to present those decisions as well as record them. **The size rule is stated per section.** An entry earns its place by being specific enough to change a later session's behavior, a section ranks what it keeps and drops whatever does not meet that bar, and what belongs somewhere durable goes there and appears here as one line and a pointer, a defect as an issue, a rule as rule text, a lesson as governance prose. The parked-decision account keeps the count and the ranked questions one round can carry, and names every issue past those by number alone, which is what keeps a queue larger than one round inside this rule. A summary held in context is re-billed until the session ends, a scratch file is read only on the machine holding it, and a closed link stays readable from any machine to every session after it. - **Re-derive state, do not carry it.** "This session already has the context" is the signal to split, not to continue. Context that has gone stale is worse than absent, because a file read hundreds of requests ago no longer describes the file. - **Compaction is a fallback, not the strategy.** It restarts context from a floor and climbs again, where a fresh session starts from zero. @@ -68,8 +69,9 @@ Bounds: If a rule you were given does not cover what you find, stop and report it. Do not guess, and do not read a governance file to resolve it. ``` -- **Wait in a background process, not in a poll loop.** A review or CI wait is a sequence of near-identical requests, each billed for whatever context it happens to carry. Run the wait as one backgrounded command that returns when the condition is met. +- **Wait in a background process, not in a poll loop.** A review or CI wait is a sequence of near-identical requests, each billed for whatever context it happens to carry. Run the wait as one backgrounded command rather than as a sequence of turns. - **A wait separates three outcomes, and says which one it reached.** The condition was met, it has not been met yet, and the wait cannot reach it at all are three different results, and a backgrounded wait that emits nothing renders all three identically. Run the command once in the foreground and read its output before backgrounding it, because a wait is only as good as the command inside it, and an unsupported flag on the installed tool version exits non-zero with an empty stdout that every naive test reads as "nothing yet". Never let a fallback stand in for a failed command, since `|| echo '[]'`, `|| true`, and `2>/dev/null` convert an error into that same reading, which is the suppression the write-safety rules already forbid on a mutation. Make the wait emit on failure as loudly as on success, so silence means "still running" and nothing else, and bound it, so a condition that is never coming ends in a report rather than in another wait. +- **Never write a wait as an unbounded shell loop.** This is a prohibition rather than a preference. A loop that waits for something, with no bound anywhere in the command that runs it, is forbidden. That holds in a tool call, in a script, and in a brief handed to a subagent. The bound goes inside that command. The wait then says which of three things it found: the condition met, the bound reached, or the check itself failing. **Prefer the mechanism that already signals.** Where the dispatch mechanism reports a subagent's completion itself, polling that subagent's output file is a second channel. The answer is already on its way. **The agent that starts a wait owns the process it leaves.** A process a tool call leaves running survives the turn, the subagent, and the run that dispatched it. So a run that dispatched workers does not report itself done while it cannot say what it left running. ## Where the Rules Live @@ -78,13 +80,13 @@ Every rule below is a level-two section of [`GOVERNANCE.md`](./GOVERNANCE.md) un | Working on | Section | | --- | --- | | Why the rules are shaped this way | `Foundational Principles` | -| Recording a durable lesson or updating governance | `Durable Knowledge and Self-Improvement`, surfaced at its decision moment by the `agent-conduct` Skill, and the section keeps the full rules | +| Recording a durable lesson, updating governance, or work here waiting on a fix in another repository | `Durable Knowledge and Self-Improvement`, surfaced at its decision moments by the `agent-conduct` Skill, and the section keeps the full rules | | Any push, API mutation, comment, label, or merge, or which checkout the work happens in | `Repository Boundaries and Write Safety`, its task-isolation rule surfaced at the task-start moment by the `repo-worktree` Skill, and the section keeps the full rules | | Quoting data into a comment, commit, test, or doc | `Representative Data in Agent-Authored Text` | | Committing, signing, rebasing, force-pushing | `Git and Commit Rules`, packaged as the `git-commit-conventions` Skill | -| Branch choice, promotion, keeping branches in sync | `Branching Model`, packaged as the `operational-vs-release-workflow` Skill | -| Releasing, version bumps, publishing | `Release Model`, packaged as the `operational-vs-release-workflow` Skill | -| A live config repo rather than a code repo | `Operational Repositories`, packaged as the `operational-vs-release-workflow` Skill | +| Branch choice, promotion, keeping branches in sync | `Branching Model`, packaged as the `branching-and-release-model` Skill | +| Releasing, version bumps, publishing | `Release Model`, packaged as the `branching-and-release-model` Skill | +| A live config repo rather than a code repo | `Operational Repositories`, packaged as the `branching-and-release-model` Skill | | Onboarding a repo or running a conformance sweep | `Repository Onboarding and Conformance` (hub only, not carried). Standing up a new repo from a hub checkout is packaged as the `standup-a-repo` Skill, resyncing one already stood up the same way is `resync-a-repo`, and measuring a named repo against the fleet ground truth per `AUDIT.md` is `audit-a-repo`, all hub-context only | | Running a fleet gate, the review digest, or the config script | `Hub-Hosted Tooling` | | Running a lint or format check locally, or a lint tool missing from `command -v` | `Running the Linters Locally (Known-Working Invocations)` (hub only, not carried) | @@ -92,11 +94,11 @@ Every rule below is a level-two section of [`GOVERNANCE.md`](./GOVERNANCE.md) un | Writing a commit message or pull request title | `Pull Request Title and Commit Message Conventions`, packaged as the `comment-and-doc-style` Skill | | Any prose, comment, doc, or line-ending change | `Documentation Style Conventions`, packaged as the `comment-and-doc-style` Skill | | Proving work actually happened | `Verification Discipline`, surfaced at its decision moment by the `agent-conduct` Skill, and the section keeps the full rules | -| Editing rule text, a Skill, or any other content other repos carry | `Verification Discipline`'s carried-content rule, whose pass the `local-strict-review` Skill runs and whose record the hub-hosted `scripts/canonical_review.py` keeps | +| Editing rule text, a Skill, or any other content other repos carry | `Verification Discipline`'s carried-content rule, which asks nothing of the change itself, its passes being run by the `local-strict-review` Skill against the units a periodic sweep names and recorded by the hub-hosted `scripts/canonical_review.py` | | Opening a pull request, or requesting, monitoring, answering, or closing a review | `PR Review Etiquette`, packaged as the `pr-review-conduct` Skill | -| Reviewing a pull request, patch, or change set | No section of its own: the `code-review` Skill, which routes to the applicable general, language, documentation, and workflow skills | -| Reporting progress or asking the user something | `Communicating with the User`, surfaced at its decision moment by the `agent-conduct` Skill, and the section keeps the full rules | -| Editing a workflow YAML file | `Workflow YAML Conventions`, surfaced with the full `WORKFLOW.md` contract by the `workflow-ci-contract` Skill, and this section and `WORKFLOW.md` keep the full rules | +| Reviewing a pull request, patch, or change set | No section of its own: the `fleet-code-review` Skill, which routes to the applicable general, language, documentation, and workflow skills | +| Reporting progress or asking the user something | `Communicating with the User`, surfaced at its decision moments by the `agent-conduct` and `session-handoff` Skills, and the section keeps the full rules | +| Editing a workflow YAML file | `Workflow YAML Conventions`, surfaced with the full `WORKFLOW.md` contract by the `workflow-ci-contract` Skill, with that section keeping the style rules and `WORKFLOW.md` the contract | | Choosing an OS, runtime, or toolchain target | `Supported Development Platforms` | | The devcontainer | `Devcontainer` | | Editor settings and tasks | `Editor and Tasks` | @@ -105,10 +107,10 @@ Every rule below is a level-two section of [`GOVERNANCE.md`](./GOVERNANCE.md) un A row above naming no Skill, or naming one only for part of its section, is doc-only by decision rather than by omission, and the reason differs by row. `Foundational Principles` is rationale read once rather than a procedure. `Repository Boundaries and Write Safety` and `Representative Data in Agent-Authored Text` are always-on law that binds whether or not a Skill fires, which is why the boundaries row names `repo-worktree` only for the one moment in it narrow enough to surface, isolating into a worktree at task start, on top of that law rather than instead of it. The `gh-write-guard` hook and the host-wide instruction blocks maintained by the hub's own agent-safety installer, hub-local at `host-setup/agent-safety/`, are the boundaries section's mechanical layer, while the data section has none, since no pattern decides it. `Running the Linters Locally (Known-Working Invocations)` is hub-only, so a carrier reaches it in a hub checkout rather than surfacing it. `Verification Discipline` carries its Skills on its other two rows. And `Hub-Hosted Tooling`, `Supported Development Platforms`, `Devcontainer`, `Editor and Tasks`, `Repository Details`, and `Repository Layout` are short reference sections a task reads at the moment it touches their subject, each already routed to by the procedures and Skills that need it. -Some of the rules above are also packaged as Claude Code / opencode / Codex Skills, hand-authored at `.agents/skills/` in the hub (not a repo-relative link here, since that path is hub-local and not carried into every fleet repo), so they surface automatically instead of needing to be re-read every session. `scripts/` is hub-hosted and reached rather than carried, per "Hub-Hosted Tooling", so run the installer from a hub checkout: `python3 scripts/skills_install.py` (or the `.sh`/`.ps1` wrapper) once per machine, from `github.com/ptr727/ProjectTemplate`, installs them for every repo touched from that machine. `python3 scripts/skills_install.py --report`, also from a hub checkout, says whether this machine is current. A rule that keeps needing to be restated is a sign the install is missing or stale, not that the rule does not exist. Keeping a repo's own carried `.github/copilot-instructions.md` in sync with the hub, without losing that repo's own "Disproved Claims" ledger entries in the process, is `copilot-instructions-keeper`, a skill about maintaining that file rather than a rule extracted from it, since the file itself is read directly by the Copilot bot and stays fully intact everywhere it is carried. Checking, from inside this repo's own session with no operator watching, whether this repo and this machine are actually current against the hub is `fleet-conformance-check`, new content rather than a rule extracted from a section, the counterpart to `resync-a-repo` that needs no standing hub checkout or named target beyond the repo the session is already in, even though its own check fetches a hub checkout to reach `scripts/skills_install.py`. Opening a pull request against a repository outside this fleet, one the maintainer does not control, follows a different workflow entirely, new content rather than a rule extracted from a section, packaged as `upstream-contribution-workflow` and independent of the target repo's own type or workflow model. Isolating a task into its own worktree before its first file edit, with the base-branch choice, the layout convention, and the cleanup mechanics, is `repo-worktree`, the task-start surface of the `Repository Boundaries and Write Safety` law, which keeps the rule. Creating, changing, or retiring one of these skills is itself packaged as `skill-lifecycle`, hub-context only, since `.agents/skills/` exists only in the hub and the generated plugin tree is never hand-edited. +Some of the rules above are also packaged as Claude Code / opencode / Codex Skills, hand-authored at `.agents/skills/` in the hub (not a repo-relative link here, since that path is hub-local and not carried into every fleet repo), so they surface automatically instead of needing to be re-read every session. `scripts/` is hub-hosted and reached rather than carried, per "Hub-Hosted Tooling", so run the installer from a hub checkout: `python3 scripts/skills_install.py` (or the `.sh`/`.ps1` wrapper) once per machine, from `github.com/ptr727/ProjectTemplate`, installs them for every repo touched from that machine. `python3 scripts/skills_install.py --report`, also from a hub checkout, says whether this machine is current. A rule that keeps needing to be restated is a sign the install is missing or stale, not that the rule does not exist. Keeping a repo's own carried `.github/copilot-instructions.md` in sync with the hub, without losing that repo's own "Disproved Claims" ledger entries in the process, is `copilot-instructions-keeper`, a skill about maintaining that file rather than a rule extracted from it, since the file itself is read directly by the Copilot bot and stays fully intact everywhere it is carried. Checking, from inside this repo's own session with no operator watching, whether this repo and this machine are actually current against the hub is `check-this-repo`, new content rather than a rule extracted from a section, the counterpart to `resync-a-repo` that needs no standing hub checkout or named target beyond the repo the session is already in, even though its own check fetches a hub checkout to reach `scripts/skills_install.py`. Opening a pull request against a repository outside this fleet, one the maintainer does not control, follows a different workflow entirely, new content rather than a rule extracted from a section, packaged as `upstream-contribution-workflow` and independent of the target repo's own type or workflow model. Isolating a task into its own worktree before its first file edit, with the base-branch choice, the layout convention, and the cleanup mechanics, is `repo-worktree`, the task-start surface of the `Repository Boundaries and Write Safety` law, which keeps the rule. Writing the handoff that "Session Scope" above requires, and resuming from one, is `session-handoff`, new content rather than a rule extracted from a section, since deciding what actually earns a place in each of the sections that rule names is judgment rather than a shape. It carries `GOVERNANCE.md` "Communicating with the User" whole as a generated include, that being the parked-decision account the rule owes, and it names the hub's `scripts/handoff.py` for the chain's mechanics. Its widest trigger is the one that earns it, about to re-attempt something a previous round may already have tried, since a session that does not know a chain exists never goes looking for one. Creating, changing, or retiring one of these skills is itself packaged as `skill-lifecycle`, hub-context only, since `.agents/skills/` exists only in the hub and the generated plugin tree is never hand-edited. Adding or changing a managed host tool is packaged as `add-host-tool`. It keeps the cross-platform contract, installer, documentation, test, and native-verification surfaces together. -Driving a pull request through its review loop, from a feature branch into `develop` and, when asked, on to a mergeable `develop -> main` promotion PR, disposing of every reviewer finding along the way per `pr-review-conduct`, is packaged as `drive-pr`, new content rather than a rule extracted from a section. Merging a ready promotion PR and dispatching the release it unblocks, refreshing this machine's installed Skills first when the repo is this hub, is `merge-and-release`, its own new-content package, invoked separately from `drive-pr` so the promotion merge and the release dispatch each keep their own explicit go-ahead. +Driving a pull request through its review loop, from a feature branch into `develop` and, when asked, on to a mergeable `develop -> main` promotion PR, disposing of every reviewer finding along the way per `pr-review-conduct`, is packaged as `drive-pr`, new content rather than a rule extracted from a section. Merging a ready promotion PR and dispatching the release it unblocks, refreshing this machine's installed Skills first when the repo is this hub, is `merge-and-release`, its own new-content package, invoked separately from `drive-pr` so the promotion merge and the release dispatch each keep their own explicit go-ahead. Working a whole open-issue backlog down by rounds, ranking the issues, grouping them so no two groups touch the same file, dispatching one subagent per group to drive its own pull request into `develop`, opening at most one `develop -> main` promotion pull request per round, and re-ranking from scratch afterwards because each round's reviews file new issues, is `backlog-burndown`, also new content rather than a rule extracted from a section. It orchestrates `drive-pr` rather than replacing it, and it scopes to the repository the session is in, and a fleet-wide issue sweep is a different request. Working the handoff chain with no maintainer present, a lean orchestrator dispatching one picker and one worker subagent per round, each worker merging only as far as the scope named at invocation and parking any handoff that meets a decision under the `blocked` label, is `unattended-handoff`, also new content, and its parked links return to the attended session `session-handoff` states. -Running one read-only, adversarial review pass against a branch's current diff against its target branch, full file context included, on the strongest model tier the session can reach, before a unit of PR-bound work is pushed toward a pull request or claimed done, is packaged as `local-strict-review`, new content rather than a rule extracted from a section. `drive-pr`, `pr-review-conduct`, and `agent-conduct` each reference it at the moment they already govern, rather than restating what it does. The rule itself lives in [`GOVERNANCE.md`](./GOVERNANCE.md) "Verification Discipline", the hub-hosted `scripts/local_review.py` is the engine that records a pass so a capture point can check one, and a repository carrying a `.husky/pre-push` hook enforces it at the push itself, the skill staying the primary and agent-agnostic layer with the hook a bypassable backstop under it. That skill carries a second pass under the same rule, over canonical content this repository authors and others carry, read one whole unit at a time rather than as a diff, because a diff-scoped read leaves the first real review of a rule to whichever repository carries it next, which is the one repository that cannot act on what it finds. `scripts/canonical_review.py` is that pass's engine, and the backlog it has yet to reach is `reports/canonical-review.md` in the hub, not a repo-relative link here since that path is hub-local like the Skills tree above. +Running one read-only, adversarial review pass against a branch's current diff against its target branch, full file context included, on the strongest model tier the session can reach, before a unit of PR-bound work is pushed toward a pull request or claimed done, is packaged as `local-strict-review`, new content rather than a rule extracted from a section. `drive-pr`, `pr-review-conduct`, and `agent-conduct` each reference it at the moment they already govern, rather than restating what it does. The rule itself lives in [`GOVERNANCE.md`](./GOVERNANCE.md) "Verification Discipline", the hub-hosted `scripts/local_review.py` is the engine that records a pass so a capture point can check one, and a repository carrying a `.husky/pre-push` hook enforces it at the push itself, the skill staying the primary and agent-agnostic layer with the hook a bypassable backstop under it. That skill carries a second pass under the same rule, over canonical content this repository authors and others carry, read one whole unit at a time rather than as a diff, because a diff-scoped read leaves the first real review of a rule to whichever repository carries it next, which is the one repository that cannot act on what it finds. That one is swept on a schedule rather than owed by a push, since owing it at every change cost more than the fleet chose to keep spending there, so a change that edits such content pushes and merges like any other. `scripts/canonical_review.py` is that pass's engine, and the units the pass has yet to reach are listed in the burn-down that engine's `report` renders from the hub's `reports/canonical-review.json`, not a repo-relative link here since that path is hub-local like the Skills tree above. diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 6fd9709..6cc045e 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -16,10 +16,13 @@ The specific rules in this file implement a few governing principles. Read these ## 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 instead of only fixing it locally. Governance is not static: it improves by agents folding good patterns in and designing bad ones out. -- **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 "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. +- **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. -This section keeps the full rules and is surfaced at its decision moment by 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. +`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. ## Repository Boundaries and Write Safety @@ -61,8 +64,8 @@ first one (never delete `develop`, resolve an EOL-only conflict by taking `devel **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 `operational-vs-release-workflow` Skill at -`.agents/skills/operational-vs-release-workflow/SKILL.md` in the hub, not a repo-relative link +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 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. @@ -71,16 +74,17 @@ 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 (Docker only) a -weekly schedule, and versioning is semantic and maintainer-controlled (NBGV owns the build number, +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, 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 `operational-vs-release-workflow` Skill at -`.agents/skills/operational-vs-release-workflow/SKILL.md` in the hub, not a repo-relative link +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, and wrapper-repo upstream-version tracking. +no-op republish guarantee, the recovery routes for a package push that fails after the release is +already cut, and wrapper-repo upstream-version tracking. ## Operational Repositories @@ -91,8 +95,8 @@ directly to `develop`, CI runs on the push as advisory feedback only, a PR still change worth reviewing, the `main` promotion gate is unchanged, and release happens only by manual dispatch. -This is packaged as part of the `operational-vs-release-workflow` Skill at -`.agents/skills/operational-vs-release-workflow/SKILL.md` in the hub, not a repo-relative link +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 when a config change still earns a pull request. @@ -122,7 +126,7 @@ A repo whose own stacks or scripts read local runtime credentials from disk, mos 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. -**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, the files the manifest declares, and the ruleset payloads its own audit diffs the live configuration against. 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, 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. **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. @@ -142,7 +146,7 @@ This is packaged as the `comment-and-doc-style` Skill at `.agents/skills/comment ## 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, 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, 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. 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. @@ -150,6 +154,10 @@ This is packaged as the `comment-and-doc-style` Skill at `.agents/skills/comment The full comment philosophy, what earns one, structure, capitalization, growth discipline, is in the `comment-and-doc-style` Skill referenced above. +### 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. + ### 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. @@ -164,68 +172,72 @@ ASD-STE100's structural half is the adopted house style: short sentences, one in ## Verification Discipline -The checks that separate work actually done from work that merely reports success. Their unifying property: **every failure below is green.** A skipped job and a passing job are indistinguishable in the aggregated required check, a pattern that matches less still exits zero, and a gate that stops gating still reports success. No linter, status check, or review layer catches any of them. +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`](./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, so finding the check never depends on how well one repository worded a pointer to it. +- **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. - **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 "PR Review Etiquette" below. -- **Canonical content one repo authors and others carry is reviewed the way a carrier reads it, whole, in the repo that can fix it.** 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 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. `scripts/canonical_review.py` records each pass keyed on the content the reviewer saw and answers whether one still covers each unit a change moved or newly carried, so a capture point can refuse exactly those rather than trusting the rule to have been remembered. A unit edited today is therefore read today, while a unit nothing has read here yet is left to the burn-down that engine's `report` writes and is never a block on unrelated work. Recording a pass writes two tracked files, that ledger and that burn-down, so where those two files land relative to the commit is a real ordering rather than a preference. Both are committed before the push, since a capture point that gates a push refuses a tree differing from HEAD before it runs either gate, while the diff receipt above is not tracked and is recorded after the last commit instead. So the ledger goes in ahead of the commit that carries it and the receipt is written after that commit, which is why the two records sit on opposite sides of it. Which repos hold such a capture point at all is a separate question, and the rule binds whether or not one is installed. Like the pass above, this one is mandatory and its findings are advisory. +- **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`. A text-mode rewrite has the mirror failure, silently flattening CRLF to LF. Prefer line-based edits (`splitlines(keepends=True)`) or literal replacement over regex reassembly. In Python the text-mode failure is the *default*: `Path.read_text()` decodes through universal newlines and `write_text()` writes `\n` back, so a read-edit-write round trip flattens the whole file while the edit itself looks correct. Pass `newline=''` to both, or work in bytes. This is the mechanism behind the Line Endings warning above, and it is worth naming because the corruption is invisible in a rendered diff. +- **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 edit source through a shell heredoc when the text carries backslash escapes.** The shell consumes the escape and writes 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. Use a file-editing tool for such text. When a check inspects text for control characters, use `str.isprintable()` rather than a codepoint floor, since DEL and the Unicode format characters sit above 32 and are equally invisible in a diff. +- **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 `gh api "repos///contents/?ref="` instead. Capture the result before decoding it rather than piping straight into `base64 -d`: `content=$(gh api "repos///contents/?ref=" --jq '.content') && printf '%s' "$content" | base64 -d`. A direct pipe reports only the decoder's own exit status, so a failed `gh api` call (a bad ref, an auth gap, a network error) can hand `base64 -d` nothing to decode, which it does successfully, misreading a failed fetch as a successful empty read. The `&&` form fails on the fetch itself instead of decoding past it. Never merge the error stream into either form with `2>&1`: a failed call's error text flows into `base64 -d` in place of content and corrupts the decode with no clue why. 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 "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 branch the repo's own model designates as current for that kind of content, not only whichever branch a worktree or checkout happened to default to, and 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, 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 fix the class.** When a reviewer cites one stale claim, one silent-narrowing pattern, or one mis-worded contract, sweep for its siblings before replying. Reviewers sample rather than enumerate. +- **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. -This section keeps the full rules and is surfaced at its decision moment by 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. +`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. ## 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. 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. 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. 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. -The provider-specific mechanics this contract needs to actually drive GitHub Copilot, how to request a review, poll for it, match the suppressed-findings heading, verify coverage, and reply or resolve a thread without a hand-typed id, live in [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) "GitHub Copilot Review Runbook", unaffected by this pointer. That content is read directly by the Copilot reviewer bot, which cannot read a Skill, so it stays fully written out rather than being packaged the same way. +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". ## 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 (`[#123](https://github.com/OWNER/REPO/pull/123)`), never a bare `#123`. 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 `#123`, 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 for input as a numbered list.** When you need the user to decide or answer, present the questions, and any options, as a numbered list so they can reply per number. A single inline question is fine, and two or more are always numbered. -- **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. This supersedes the numbered-list rule above wherever an interactive prompt is available, and the numbered list is the fallback where none is. +- **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. -This section keeps the full rules and is surfaced at its decision moment by 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. +`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. ## Workflow YAML Conventions -These conventions describe the target state. New and modified workflows must respect them. The rest of the repo is expected to be brought up to the same standard. Sweep PRs that apply a rule everywhere are welcome when a rule changes. +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. -This section and [`WORKFLOW.md`](./WORKFLOW.md) keep the full rules, this section winning where the two overlap, and both are surfaced by 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. +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. 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**: reusable workflows (those with `on: workflow_call`) end in `-task.yml`. Entry-point workflows (`on: push` / `pull_request` / `schedule` / `workflow_dispatch`) do NOT use the `-task` suffix. They end with what they do: `-pull-request.yml`, `-release.yml`, etc. The suffix carries semantic meaning: a `-task.yml` file is meant to be `uses:`-d, never triggered directly. -- **Workflow `name:`** (the top-level `name:` field): reusable workflow names end in **"task"** (e.g. `Build project release task`), and entry-point workflow names end in **"action"** (e.g. `Publish project release action`, `Test pull request action`). The displayed action name in the GitHub Actions UI tells you at a glance whether you're looking at an orchestrator or a callee. -- **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 ruleset-bound job's `name:` and its ruleset `context:` are the **same string**: rename them **together**, updating the live ruleset and the hub's `repo-config/` payloads in lockstep with the job `name:`, never one without the other, or required-status-check enforcement silently breaks. There is no un-suffixed exception. -- **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. **Documented exceptions** (both record the rationale inline in their header comment): (1) [`merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml) 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 uses `cancel-in-progress: false` because the merge-bot's job model (enable-auto-merge on opened, disable-auto-merge on maintainer-pushed synchronize, with method dispatched by base) requires each event to run to completion in arrival order, because cancellation would leave auto-merge in an inconsistent state. (2) `.github/workflows/publish-release.yml` uses both a **global, ref-independent group** (`group: ${{ github.workflow }}`, dropping the usual `-${{ github.ref }}`) and `cancel-in-progress: false`. It publishes shared ref-independent artifacts (both branches' Docker tags/caches and GitHub releases) on schedule/dispatch regardless of the triggering ref, so a ref-scoped group would let a scheduled run (ref `main`) and a manual dispatch (ref `develop`) run concurrently and double-push, and cancelling a publish mid-flight can leave a partially pushed tag set or a half-created release. The global group + queueing serializes every publish run to completion. -- **Shells**: every bash surface, a multi-line `run:` block and every committed `.sh` 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`). 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. -- **Conditionals**: multi-line `if:` uses folded scalar `if: >-` so YAML preserves whitespace correctly. Literal block (`if: |`) is wrong because it embeds newlines inside the boolean expression. -- **Boolean inputs**: workflows triggered both via `workflow_call` and `workflow_dispatch` must declare each boolean input in *both* trigger blocks, since one definition does not propagate to the other. `workflow_call` delivers booleans as actual booleans, and `workflow_dispatch` delivers them as the *strings* `"true"`/`"false"`. Any `if:` consuming a boolean input must compare against both forms: `if: ${{ inputs.foo == true || inputs.foo == 'true' }}`. -- **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** in a dedicated entry validation step/job that the downstream jobs `needs:`, before any expensive build or publish work, not as partial checks scattered deep in later jobs. One gate that fails fast with a clear `::error::` beats a late or one-directional check. Examples: `build-release-task.yml`'s `validate-release` job (branch-versus-prerelease, both directions) and `publish-docker-readme-task.yml`'s "Validate inputs step". +- **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')`. -- **Artifact retention**: workflow artifacts are an intra-run handoff only, with durable copies living on the GitHub release rather than in workflow artifacts, so they 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 **same condition as the consuming step**, so a no-op re-run that skips the consumer skips the delete with it and leaves the freshly built assets alone (the `github-release` job deletes `release-asset--*` after attaching them to the release, and a package repo's `publish-release.yml` deletes `nuget-build-` or `pypi-build-` in the `publish-` job that consumed it). 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. 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/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. A **multi-image** repo uses a **per-image** buildcache tag (`:buildcache-` for each image, plus the base image's own tag and inline cache). It does not fall back to `type=gha` for the extra images. +- **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). ## Supported Development Platforms From 9093ebef0826dae359d5e30d24c63aabdd71a25a Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 25 Sep 2026 19:22:50 -0700 Subject: [PATCH 2/3] Re-vendor the Skills Tree and Copilot Instructions From the Hub Carries the renamed fleet-code-review, branching-and-release-model and check-this-repo skills, prunes their superseded copies, and points the Copilot bootstrap at fleet-code-review. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/copilot-instructions.md | 53 +- .github/skills/agent-conduct/SKILL.md | 113 +++- .github/skills/audit-a-repo/SKILL.md | 6 +- .github/skills/backlog-burndown/SKILL.md | 527 ++++++++++++++++++ .../SKILL.md | 41 +- .../branch-protection-and-promotion.md | 12 +- .../references/release-publish-mechanics.md | 65 ++- .../carried-instruction-file-guard/SKILL.md | 13 +- .../SKILL.md | 36 +- .github/skills/comment-and-doc-style/SKILL.md | 123 ++++ .../references/carried-doc-references.md | 17 +- .../copilot-instructions-keeper/SKILL.md | 19 +- .github/skills/dotnet-codestyle/SKILL.md | 23 +- .../dotnet-codestyle/references/testing.md | 16 + .github/skills/drive-pr/SKILL.md | 132 +++-- .../SKILL.md | 12 +- .github/skills/local-strict-review/SKILL.md | 86 +-- .github/skills/merge-and-release/SKILL.md | 50 +- .github/skills/pr-review-conduct/SKILL.md | 207 +++++-- .github/skills/python-codestyle/SKILL.md | 12 +- .../python-codestyle/references/profiles.md | 2 - .../python-codestyle/references/testing.md | 2 + .github/skills/repo-worktree/SKILL.md | 56 +- .github/skills/resync-a-repo/SKILL.md | 35 +- .github/skills/session-handoff/SKILL.md | 323 +++++++++++ .github/skills/skill-lifecycle/SKILL.md | 25 +- .github/skills/standup-a-repo/SKILL.md | 2 +- .github/skills/unattended-handoff/SKILL.md | 250 +++++++++ .../upstream-contribution-workflow/SKILL.md | 2 +- .github/skills/workflow-ci-contract/SKILL.md | 57 +- .../references/architecture.md | 112 ++++ .../references/d-guarantees.md | 119 ++-- .../references/test-methodology.md | 64 ++- 33 files changed, 2177 insertions(+), 435 deletions(-) create mode 100644 .github/skills/backlog-burndown/SKILL.md rename .github/skills/{operational-vs-release-workflow => branching-and-release-model}/SKILL.md (86%) rename .github/skills/{operational-vs-release-workflow => branching-and-release-model}/references/branch-protection-and-promotion.md (94%) rename .github/skills/{operational-vs-release-workflow => branching-and-release-model}/references/release-publish-mechanics.md (60%) rename .github/skills/{fleet-conformance-check => check-this-repo}/SKILL.md (69%) rename .github/skills/{code-review => fleet-code-review}/SKILL.md (89%) create mode 100644 .github/skills/session-handoff/SKILL.md create mode 100644 .github/skills/unattended-handoff/SKILL.md create mode 100644 .github/skills/workflow-ci-contract/references/architecture.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 18c14b4..9612277 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,53 +2,35 @@ Repository-wide instructions for GitHub Copilot. -Read [AGENTS.md](../AGENTS.md) first. It routes every standing repository rule to its canonical -document. When performing code review, load and follow the `code-review` skill in -`.github/skills/code-review/SKILL.md`, then load every language, documentation, or workflow skill -that it selects for the changed files. GitHub Copilot reads these files from the pull request's -head branch, so review the instructions in that tree. +Read [AGENTS.md](../AGENTS.md) first. It routes every standing repository rule to its canonical document. When performing code review, load and follow the `fleet-code-review` skill in `.github/skills/fleet-code-review/SKILL.md`, then load every language, documentation, or workflow skill that it selects for the changed files. GitHub Copilot reads these files from the pull request's head branch, so review the instructions in that tree. -Do not duplicate rules from `AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, or `WORKFLOW.md` here. -This file contains only Copilot-specific bootstrap and output requirements. +Do not duplicate rules from `AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, or `WORKFLOW.md` here. This file contains only Copilot-specific bootstrap and output requirements. ## Commit Messages and Pull Request Titles -Use an imperative subject of at most 72 characters with no trailing period. Use US English and -title case with lowercase short bind words. Do not add `Co-Authored-By:` unless requested. Do not -put a release-bump magnitude in the title. The full contract is in -[GOVERNANCE.md "Pull Request Title and Commit Message Conventions"](../GOVERNANCE.md#pull-request-title-and-commit-message-conventions). +Use an imperative subject of at most 72 characters with no trailing period. Use US English and title case with lowercase short bind words. Do not add `Co-Authored-By:` unless requested. Do not put a release-bump magnitude in the title. The full contract is in [GOVERNANCE.md "Pull Request Title and Commit Message Conventions"](../GOVERNANCE.md#pull-request-title-and-commit-message-conventions). ## Reviewing Carried Fleet Content -Follow the fidelity declared for the file. A byte-locked reference to shared infrastructure that -this repository does not carry is intentional, not a broken link. Raise substantive defects in -canonical content, but locate the fix at its canonical source instead of proposing a local edit -that its fidelity rejects. +Follow the fidelity declared for the file. A byte-locked reference to shared infrastructure that this repository does not carry is intentional, not a broken link. Raise substantive defects in canonical content, but locate the fix at its canonical source instead of proposing a local edit. + +`.github/skills/`, and in the hub `.claude-plugin/fleet-skills/`, are generated by the hub's `scripts/build_dist.py` from its `.agents/skills/`, so a defect in either is fixed in the source or the generator and never in the copy. A defect inside an include region, the text between the marker lines `` and `` that every copy carries as its authored source does, is fixed in the hub under the heading that key names, since the region is generated from that heading's body and the key's path resolves against the hub's root rather than this repository's copy of the same file. Where that heading's body is itself a region, the fix sits one hop further, under the heading its own key names. Post no review comment on a file under `.github/skills/` or, in the hub, `.claude-plugin/fleet-skills/`. When the pull request changes the file the fix belongs in, comment on that file instead, and otherwise state the finding in the review summary. ## GitHub Copilot Review Runbook For every review: 1. Read the full pull request diff and count its changed files. -2. Follow `.github/skills/code-review/SKILL.md` and every skill it selects. -3. Publish every supported finding. Never suppress a finding or place it in a low-confidence or - hidden findings block. -4. Use an inline comment when a changed line can anchor the finding. Use the review body only when - no valid inline anchor exists. -5. End the review body with the exact machine-readable marker required by the `code-review` skill. - -The review automation is `scripts/pr_review.py`, run from a hub checkout. Use its `status`, `wait`, -`comment`, and `reply --resolve` commands instead of reconstructing GraphQL queries or copying -review identifiers by hand. Use `comment` for a suppressed-finding answer in the pull request -conversation. Its status gate verifies the current head, diff coverage, output shape, inline -threads, body-only findings, and required checks. - -A formal review with no findings is complete only when it covers the current head and states full -diff coverage. A refusal, partial or absent coverage statement, unrecognized output shape, -unresolved thread, or body-only finding blocks the review loop. Re-run the loop after every fix -push. Never infer review completion from `mergeStateStatus: CLEAN`. - -Review effort is user-controlled. The automation observes `Lite`, `Balanced`, or `Max`, including an inherited `Default ()`, and never selects or changes the setting. Effort does not determine coverage or completion. A request can complete without a `copilot_work_started` event, so absence of that event is not a stalled-review verdict. When `wait` returns `PENDING` with `requested=yes`, report the state and rerun `wait` for another bounded interval by default. Do not clear the request automatically because it may be active. If the maintainer directs a retry, remove Copilot in the pull request UI, add it again, and rerun `wait`. This recovery replaces only the review request and never changes the effort setting. +2. Follow `.github/skills/fleet-code-review/SKILL.md` and every skill it selects. +3. Publish every supported finding. Never suppress a finding or place it in a low-confidence or hidden findings block. +4. Use an inline comment when a changed line can anchor the finding. Use the review body only when no valid inline anchor exists. +5. End the review body with the exact machine-readable marker required by the `fleet-code-review` skill. + +The review automation is `scripts/pr_review.py`, run from a hub checkout. Use its `status`, `wait`, `comment`, and `reply --resolve` commands instead of reconstructing GraphQL queries or copying review identifiers by hand. Use `comment` for a suppressed-finding answer in the pull request conversation. Its status gate verifies the current head, diff coverage, output shape, inline threads, body-only findings, and required checks. + +A formal review with no findings is complete only when it covers the current head and full diff coverage is stated for the change set that head has. The round covering the head states it, or the newest round that states it at all does and the pull request changes the same set of files at both commits, which is the only condition under which a statement carries forward. Only that newest round is consulted, so an older round whose change set does match carries nothing. A round reporting partial coverage of the diff blocks the merge, and so does a refusal, a coverage statement that does not reach this head, meaning absent from every round or carried by none because the change set moved or could not be compared, an unrecognized output shape, an unresolved thread, or a body-only finding. Re-run the loop after every fix push. Never infer review completion from `mergeStateStatus: CLEAN`. + +Review effort is user-controlled. The automation observes `Lite`, `Balanced`, or `Max`, including an inherited `Default ()`, and never selects or changes the setting. Effort does not determine coverage or completion. A request can complete without a `copilot_work_started` event, so absence of that event is not a stalled-review verdict. When `wait` returns `PENDING` with `requested=yes`, report the state and rerun `wait` for another bounded interval by default, reading that field as acceptance of the request rather than as delivery of a round. Do not clear the request on that first timeout, because it may still be active. Where a second bounded wait times out as well, read the pending set, clear it only where no human or team reviewer is requested alongside the bot, and rerun `wait`, which then has nothing outstanding to defer to and requests afresh, or polls and says so on its own auto-request line where it finds no reviewer node id to request with. The clear replaces that set rather than adding to it and nothing restores a request it drops, so a stall on a pull request that has a human or team reviewer requested goes to the maintainer, and so does one still pending after the wait that follows a clear. That clear is a recovery step the script does not implement, and `docs/pr-reviewer-reference.md`, in the hub checkout the script is run from, carries it. This recovery replaces only the review request and never changes the effort setting. ### Disproved Claims @@ -64,5 +46,4 @@ This repository has no entry yet. A finding disproved against this repository's ## When in Doubt -Stop and report the uncertainty. Do not guess at an instruction, suppress a possible finding, or -claim coverage that the review did not perform. +Stop and report the uncertainty. Do not guess at an instruction, suppress a possible finding, or claim coverage that the review did not perform. diff --git a/.github/skills/agent-conduct/SKILL.md b/.github/skills/agent-conduct/SKILL.md index 9f20255..e869ad1 100644 --- a/.github/skills/agent-conduct/SKILL.md +++ b/.github/skills/agent-conduct/SKILL.md @@ -1,47 +1,114 @@ --- name: agent-conduct description: >- - Surfaces the ptr727/ProjectTemplate fleet's conduct rules at the three decision moments they are violated: about to claim work is done, verified, green, or fixed, about to proceed on an assumption the user could cheaply confirm, and a failure or review finding just surfaced a durable lesson. Use this whenever about to report success or completion of any task, whenever about to pick a default, guess an intent, or resolve an ambiguity without asking, whenever work is blocked on a decision or authorization only the user can give, and whenever an incident, a wrong answer, or a repeated correction just taught something a future session must honor. Deliberately narrow: the carried AGENTS.md sections are the always-on layer, and this skill fires at the moments rather than duplicating them, so do not load it as general background. Where a sibling skill owns the moment, it wins: git-commit-conventions for committing, pr-review-conduct for review and merge claims, comment-and-doc-style for prose. The GOVERNANCE.md sections this skill summarizes keep the full rules. + Surfaces the ptr727/ProjectTemplate fleet's conduct rules at the decision moments they are + violated. Use this whenever about to claim work is done, verified, green, or fixed, whenever + about to pick a default, guess an intent, or resolve an ambiguity without asking, whenever work + is blocked on a decision or authorization only the user can give, whenever about to ask the + user anything or offer them more work, a closing "want me to...?" line included, whenever about + to file a question as an issue instead of asking it, whenever writing a handoff, which owes an + account of every question parked rather than asked, whenever work here waits on a fix in + another repository, and whenever an incident, a wrong answer, or a repeated correction just + taught something a future session must honor. Deliberately narrow: the carried AGENTS.md + "Context and Delegation Discipline" section is the always-on layer, so do not load this skill + as general background. + Where a sibling skill owns the moment, it wins: git-commit-conventions for committing, + pr-review-conduct for review and merge claims, local-strict-review for the passes a push owes, + comment-and-doc-style for prose. The GOVERNANCE.md sections it surfaces keep the full rules, + carried here whole as generated includes. --- # Agent Conduct ## Why This Exists -The fleet's conduct rules (verification before claiming done, asking instead of assuming, recording lessons) lived only in doc sections nothing surfaced at the moment of violation, so they were honored by whoever happened to have read them recently. This skill is the decision-moment surface. The full rules stay in `GOVERNANCE.md` ("Verification Discipline", "Communicating with the User", "Durable Knowledge and Self-Improvement"), which keeps authority, and in the carried `AGENTS.md` "Context and Delegation Discipline" section, which is the always-on layer. +The fleet's conduct rules (verification before claiming done, asking instead of assuming, recording lessons, accounting at handoff for what was parked rather than asked, recording a blocker that lives in another repository) lived only in doc sections nothing surfaced at the moment of violation, so they were honored by whoever happened to have read them recently. This skill is the decision-moment surface. The full rules stay in `GOVERNANCE.md` ("Verification Discipline", "Communicating with the User", "Durable Knowledge and Self-Improvement"), which keeps authority, and each of those three sections is carried here whole, as a generated include that `scripts/build_dist.py` fills from the section and holds to it, so the text that surfaces at the moment is the rule's own rather than a shorter list of it. The carried `AGENTS.md` "Context and Delegation Discipline" section is the always-on layer and is not carried here. A defect in included text is fixed in `GOVERNANCE.md` and regenerated, never edited in this file, per the `skill-lifecycle` Skill. ## Before Claiming Done -Read `GOVERNANCE.md` "Verification Discipline" before reporting success on anything non-trivial. Its unifying property: every failure it lists is green. The checks that bind here: +Read the section below before reporting success on anything non-trivial. It is `GOVERNANCE.md` "Verification Discipline", whole. -- **A green check is not evidence the work happened.** A skipped job and a passing job are indistinguishable in an aggregated required check, so confirm from the log that the job ran and produced what it promises. -- **Locate every check the change owes before running any**, from what the repository declares (`OPERATIONS.md` "Local Verification" beside the workflows), not from what the pipeline happens to run, since part of a contract is routinely unreachable from a runner and green is then the precise signal it was skipped. -- **Run the repo's whole lint gate before every push**, not the parts that look relevant, because the tool most likely to catch a change is often the one it seems least about. -- **A launched process is not a result.** Report the output the wait produced, and where it produced none, that absence is the report. Never name an external cause the record does not carry. -- **A local clone is not the branch it names.** Fetch immediately before reading, or read the live ref, and name the ref and commit in any finding a local read produced. -- **A checkout this session did not create is not ground truth.** One found already sitting on disk may belong to another concurrent session, sit on a stale fetch or an unexpected branch, or hold unreviewed uncommitted edits. Clone fresh or read the live API instead of trusting `git status`/`git remote -v` run against a pre-existing checkout. -- **A "does not exist" claim names the branch it was checked against.** A worktree's default branch is not necessarily the one the content lives on: in-flight content on a `release`-model repo lands on `develop` before `main`, per `GOVERNANCE.md` "Branching Model," so check that branch before reporting anything absent repo-wide. -- **A `raw.githubusercontent.com` 404 does not distinguish a private repository from a missing file.** Where visibility is not confirmed public, read content via `gh api "repos///contents/?ref="`, capturing the result before decoding it (`content=$(gh api ... --jq '.content') && printf '%s' "$content" | base64 -d`) rather than piping straight into `base64 -d`, whose own exit status is all a direct pipe reports, letting a failed fetch decode as an empty success. Never `2>&1` either form, which corrupts the decode with the error text instead of the payload. Verify the ref resolves before reading either failure as proof the content itself does not exist. -- **A test asserts the mechanism it names, and a gate has to be watched failing.** A case that passes for an incidental reason is worse than no case, because it is later cited as evidence. -- **Platform-specific code is verified only on the platform it runs on.** Reasoning about PowerShell, macOS, or WSL-specific behavior from a different host is not verification, however closely it matches an already-tested equivalent elsewhere. State an untested structural match as exactly that, never in the words used for a tested fact, and when no agent in the loop has access to the target platform, say so and defer or ship it labeled unverified. -- **PR-bound work runs `local-strict-review` before the claim, and records the pass.** Claiming a unit of work done, verified, green, or fixed for work that will become, or already is, a pull request means running `local-strict-review` against the branch's diff first, before a PR-hosted reviewer finds the same gap, and recording that pass with a hub checkout's `scripts/local_review.py`, run with this repository as the working directory since the engine records into whichever repository the cwd sits in, per that skill's own commands. In the repository that authors canonical content others carry, a change moving one of its units owes a second pass over that unit's whole text, recorded with `scripts/canonical_review.py` before the commit, since its ledger is tracked. Where a capture point exists it then checks what applies. Every push toward a pull request owes one, the fix pushes answering review findings included, which is the round it is most often skipped on. + -Claims about a pull request being reviewed, clean, or mergeable are owned by the `pr-review-conduct` skill, and claims that a commit landed by `git-commit-conventions`. +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. +- **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. + +`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. + + + +Claims about a pull request being reviewed, clean, or mergeable are owned by the `pr-review-conduct` skill, and claims that a commit landed by `git-commit-conventions`. The two review passes the section above requires, one over a push's diff and one over each canonical unit a sweep names, their delegation shape, and how each is recorded are the `local-strict-review` skill's. ## Before Assuming - **Ask when the user can cheaply confirm.** An assumption that saves one question and is wrong costs the rework plus the trust, so a genuine ambiguity in intent, scope, or authorization is raised, not resolved by picking the likelier reading. Rules that already answer the question (the committed instruction set) are not ambiguity, so read them first rather than asking what they state. -- **Raise blocked work as a direct interactive prompt** at the point the work stops, per `GOVERNANCE.md` "Communicating with the User": the blocked item is the message, the options offered are the actions themselves, and a handoff buried in a summary paragraph is a handoff that did not happen. Numbered lists are the fallback where no prompt mechanism exists. -- **References are clickable where they are read**: a pull request, issue, or commit on a Markdown surface is a Markdown link, and on a surface that renders neither, a bare `#123` with the link in the message before the prompt. -- **Capability is not permission.** A token's reach, a tool that happens to work, or a similar grant in a past session authorizes nothing, and the irreversible step (merge, publish, release, delete) stays the maintainer's. +- **The irreversible step (merging, publishing, releasing, force-pushing, deleting, changing branch protection) stays the maintainer's, and a grant given in a past session or for a different task authorizes nothing now.** Whether a credential's reach or a tool that happens to work authorizes anything is answered by `GOVERNANCE.md` "Repository Boundaries and Write Safety" rather than here. + +`GOVERNANCE.md` "Communicating with the User", carried whole below, covers how to ask, how to reference an issue, a pull request, or a commit wherever it is mentioned, how to raise work that is blocked on the answer, and what a session owes for the questions it parked rather than asked. + + + +- **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. + +`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. + + + +## Before Writing a Handoff + +Three sources govern this moment, and the first two are rules in the `GOVERNANCE.md` "Communicating with the User" include under "Before Assuming": the one opening "A question filed as an issue is parked rather than asked" and the one opening "The session that writes a handoff presents the parked queue in the same act". They state the obligation, how the queue is enumerated, how it is ranked, and what to do where no prompt or no user is available. The third is `AGENTS.md` "Session Scope", which the second of those bullets names and which keeps the handoff's own size rule and names what its sections carry, while the `session-handoff` Skill fixes their headings and their order and is what writes one and resumes from one. Read all three there, since a restatement here would be a second copy, and nothing would hold it to the first. ## When a Failure Surfaces a Lesson -- **Durable knowledge lands in the committed docs, not in agent memory**, as part of the change that surfaced it, per `GOVERNANCE.md` "Durable Knowledge and Self-Improvement". Memory does not survive a new session or machine, so it holds only environment nuance and in-flight state. -- **Where the governing doc is carried from the hub, file the finding against `ptr727/ProjectTemplate`** rather than only patching it locally. A local fix leaves every sibling repo with the same trap. Search open and closed issues first, then update the matching issue or file a new one. -- **A review flags an instance, so fix the class**: sweep for the siblings before replying, because reviewers sample rather than enumerate. -- **A rule that keeps needing restating** is usually a stale or missing skills install, so run `python3 scripts/skills_install.py --report` from a hub checkout (the `fleet-conformance-check` skill) before concluding the rule does not exist. +Where a lesson lands, when it earns a mechanical hook, and where work blocked on another repository is recorded are all `GOVERNANCE.md` "Durable Knowledge and Self-Improvement", whole. + + + +- **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. + +`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. + + + +Two rules that bind at this moment have their homes elsewhere. A review flags an instance, so a fix covers the class, bounded to what the change touched or broke, stated under "Before Claiming Done" above. And a rule that keeps needing to be restated is a stale or missing skills install before it is a missing rule, per `AGENTS.md` "Where the Rules Live", with the `check-this-repo` Skill as the check. + +## When Work Stops on Another Repository + +The rules for this moment are two bullets in the `GOVERNANCE.md` "Durable Knowledge and Self-Improvement" include under "When a Failure Surfaces a Lesson" above. The first, opening "A blocker filed in another repository is recorded in the repository whose work it blocks", states what the two issues are, what the local one says, the `blocked` label it carries, and the backlink the upstream issue takes in return. The second, opening "The blocker record is written under the ordinary write rules", states which write rules bind those writes, what a repository not carrying the `blocked` label owes before it can host the record, and when the label comes off. Read both there, since a restatement here would be a second copy and nothing would hold it to the first. ## Delegation, in One Paragraph -The always-on rules live in `AGENTS.md` "Context and Delegation Discipline" and are not restated here. The two that intersect conduct: brief a subagent so it never needs a governance file, since anything it must honor has to be in its prompt, and never tier down the seat holding the judgment, because governance wording and the decision to decline a review finding are fleet-wide and durable when wrong. +The always-on rules live in `AGENTS.md` "Context and Delegation Discipline", loaded in every session, and are not restated here. The two that bind at a conduct moment are its rule on briefing a subagent and its rule on never tiering down the seat holding the judgment. diff --git a/.github/skills/audit-a-repo/SKILL.md b/.github/skills/audit-a-repo/SKILL.md index 5cd0c13..487dd8e 100644 --- a/.github/skills/audit-a-repo/SKILL.md +++ b/.github/skills/audit-a-repo/SKILL.md @@ -1,7 +1,7 @@ --- name: audit-a-repo description: >- - Drives AUDIT.md's read-only measurement of a named ptr727 fleet repo against the fleet ground truth, ending in a committed report, never an edit to the repo being measured. Use this whenever asked to audit, measure, or verify conformance of a named repo, to judge a conformance claim someone else made, or to decide whether an onboarding is actually complete. Run from a hub checkout of ptr727/ProjectTemplate against the named target. Triggers even when the repo believes it is conformant, because conformance asserted without a committed report is conformance nobody can check, and that is the case most often skipped. This completes the procedure triangle: standup-a-repo creates a repo, resync-a-repo applies findings to one already stood up, and this skill measures, while fleet-conformance-check is the in-repo self-check with no named target and no standing hub checkout. AUDIT.md keeps authority over the procedure, this skill is the summary that routes into it. + Drives AUDIT.md's read-only measurement of a named ptr727 fleet repo against the fleet ground truth, ending in a committed report, never an edit to the repo being measured. Use this whenever asked to audit, measure, or verify conformance of a named repo, to judge a conformance claim someone else made, or to decide whether an onboarding is actually complete. Run from a hub checkout of ptr727/ProjectTemplate against the named target. Triggers even when the repo believes it is conformant, because conformance asserted without a committed report is conformance nobody can check, and that is the case most often skipped. This completes the procedure triangle: standup-a-repo creates a repo, resync-a-repo applies findings to one already stood up, and this skill measures, while check-this-repo is the in-repo self-check with no named target and no standing hub checkout. AUDIT.md keeps authority over the procedure, this skill is the summary that routes into it. --- # Audit a Repo @@ -18,10 +18,10 @@ The audit is the fleet's measurement procedure, and the two failure shapes it gu ## Measuring -- **Resolve the repo's types from `registry/repos.json`** and classify a `classificationPending` entry from the tree (`AUDIT.md` section 2). The applicability gate is `WORKFLOW.md` section 1: a check governing an absent construct is N/A, excluded from the verdict, and never a defect (`AUDIT.md` section 3). +- **Resolve the repo's types from `registry/repos.json`** and classify a `classificationPending` entry from the tree (`AUDIT.md` section 2). The applicability gate is `WORKFLOW.md` section 1, extended to `AUDIT.md`'s own checks: an item or check governing an absent construct is N/A, excluded from the verdict, and never a defect (`AUDIT.md` section 3). - **Know what the runner does and does not prove.** `spec/audit.py` mechanizes the deterministic subset only: settings, rulesets, secret names, file and section presence, verbatim hashing, interface wiring, Dependabot coverage, branch facts. It evaluates no check under a type in `spec/project-types.json`, so every per-type check is judged by hand, and a clean run is no evidence for them (`AUDIT.md` section 4). Silence from a tool that was never looking reads exactly like a pass. - **Judge letter and intent per check** and keep the vocabulary: letter miss with intent satisfied is a drift finding, both missing is a defect, and operational is binary over the applicable set (`AUDIT.md` sections 4 and 7). Do not invent a parallel scheme. -- **Assert the Actions implement `WORKFLOW.md`** by outcome, not by matching catalog snippets byte for byte: the 5A static audit with a `file:line` citation per applicable guarantee, then the 5B trace scenarios (`AUDIT.md` section 5). The `workflow-ci-contract` skill summarizes that contract. +- **Assert the Actions implement `WORKFLOW.md`** by outcome, not by matching catalog snippets byte for byte: the 5A static audit, each applicable guarantee cited in the form 5A sets out, then the 5B trace scenarios (`AUDIT.md` section 5). Read a workflow the repo only calls at the SHA it pins, for both. The `workflow-ci-contract` skill summarizes that contract. - **Check live settings, rulesets, and secrets from a hub checkout at `main`** with `AUDIT.md` section 6. Run `repo-config/configure.sh check` with the target repository and model for settings and rulesets, and `spec/audit.py [RepoName]` for secrets, rather than constructing a local comparison. The hub payloads are the only repository-configuration source. ## Reporting diff --git a/.github/skills/backlog-burndown/SKILL.md b/.github/skills/backlog-burndown/SKILL.md new file mode 100644 index 0000000..bb4c611 --- /dev/null +++ b/.github/skills/backlog-burndown/SKILL.md @@ -0,0 +1,527 @@ +--- +name: backlog-burndown +description: >- + Burns a ptr727/ProjectTemplate fleet repository's open-issue backlog down by rounds: rank the + open issues, group them so no two groups touch the same file, dispatch one subagent per group to + drive its own feature -> develop pull request to merge, open at most one develop -> main + promotion pull request per round for the maintainer to merge, then re-rank and go again, because + every review round files new issues that change what the next round should pick. Use this + whenever asked to work the backlog, burn the backlog down, clear the open issues, resolve or + cull the backlog, or run issues in parallel until they are gone, and whenever the ask is a + standing one rather than a single named issue. Triggers even when the backlog looks small enough + to work by hand, because the failure it exists to prevent is two agents editing the same + prose-heavy Markdown file in the same round, which surfaces as a merge conflict long after both + branches are already deep in review. Drives one repository, the one the session is in, never a + fleet-wide sweep. Ends when a re-rank finds nothing left it can act on, and never merges main, + which stays the maintainer's own step through merge-and-release. With no maintainer present, the + loop is `unattended-handoff` instead, which works one issue needing no decision per round and + parks the rest. +--- + +# Backlog Burndown + +## Why This Exists + +Asking for one issue to be fixed is `drive-pr`'s job and needs no skill above it. Asking for a +whole backlog to be worked down is a different problem, and three things about it are not obvious. +Parallelism is bounded by file overlap rather than by agent count, so the grouping decides the +throughput. The backlog is not a fixed list, since every review round files deferral issues that +belong in the next round's ranking, so a plan made once is stale by its second round. And a +prose-heavy repository conflicts on content rather than on syntax, so two agents rewording the +same section produce a conflict no tool resolves and no reviewer catches early. + +## The Two Seats + +Everything below turns on which seat is acting, so both are named once here. + +- **The orchestrator** is the session this skill runs in. It ranks, groups, dispatches, and drives + the promotion pull request. It opens no feature branch and fixes no issue itself, which is what + keeps it out of every worker's files. It does write: it comments on issues, it drives and + amends the promotion pull request, and it owns worktree and branch cleanup, which "Dispatching a + Worker" states in full. +- **A worker** is one dispatched subagent holding one group, one worktree, and one feature branch, + the dispatched task `AGENTS.md` "Session Scope" describes. It drives its own pull request into + develop and ends there. + +## Scope + +One repository, the one the session is in, resolved from its own `origin`. A run staying inside the +one repository it was invoked for is narrower than `GOVERNANCE.md` "Repository Boundaries and Write +Safety" requires, deliberately. Reading another repository's issues is governed there and not here, +working them is out of this skill's scope, and a fleet-wide backlog sweep is a different request. + +## What Invoking This Skill Authorizes + +- Naming this skill is the maintainer's explicit go-ahead for the feature -> develop squash merges + this run performs, in every round of it. A per-round merge question would idle every agent at + every boundary, which is the thing this skill exists to avoid. +- **The grant is bounded by the session it was named in.** A run interrupted and resumed in a new + session needs the skill named again, which costs one sentence and is the difference between a + grant and a mode. A grant read back from a note is one nobody gave. +- The grant does not weaken the `pr-review-conduct` Merge Gate. It answers that gate's explicit-permission item for + this run's feature -> develop merges and nothing else, so a pull request with one open finding + still does not merge. +- It is never authorization to merge a develop -> main promotion pull request, to dispatch a + release, to close an issue on judgment, or to touch another repository. Each stays the + maintainer's, and merging a promotion pull request is `merge-and-release`, invoked on its own. + +## The Round + +A round is the unit. Each one runs these steps in order. + +1. **Rank** every open issue, per "Ranking". +2. **Group** the top of that ranking, per "Grouping and File Claims". +3. **Verify** each group's predicted file set against everything in flight before dispatching + anything. A group whose files are already claimed waits for the next round. +4. **Dispatch** at most four workers, one per group, per "Dispatching a Worker". +5. **Collect** each worker's outcome: merged to develop, stopped on a question only the + maintainer can answer, parked behind another group's file claim, or abandoned, which is what + the adjudication in "Grouping and File Claims" and a confirmed-gone worker both produce. Bound + this wait per "Bounding the Wait on a Worker". +6. **Clean up** the worktrees, local branches, and merged remote branches of every group that has + finished or been abandoned, per "Dispatching a Worker". +7. **Promote**, per "The Promotion Boundary". +8. **Re-rank from scratch**, and note that the next round prepares under the freeze "The Promotion + Boundary" describes whenever a promotion pull request is still waiting on the maintainer, so it + ranks, groups, and verifies claims, and dispatches nothing until that merge lands. Do not carry + the previous round's ranking forward. The deferral issues this round's reviews filed are now + open issues with a claim on the next round's attention, and an issue that ranked low last round + can rank high once a sibling fix lands. + +## Ranking + +Where the repository carries no priority label, and the hub does not, the ranking is the +orchestrator's judgment against stated criteria rather than a field read off the issue. Where a +repository does carry one, that label is the first input and these criteria order what it leaves +tied. Write the ranking, and the reason for the top of it, into the report this skill makes at +each round boundary, per "Ending the Run". + +Rank on these, highest first where they conflict: + +- **It blocks other work.** An issue whose fix changes a rule, a gate, or a shared contract that + other issues' fixes must then obey is worth doing before them, not after. +- **It is a correctness or safety defect** in something that runs, over an improvement to + something that reads. +- **It is a root cause rather than a leaf.** A parent issue grouping several filed symptoms is + worth more than any one of its children, and fixing it may close them. +- **It is new.** An issue filed by a recent review round is evidence of something the current + content actually got wrong, and it is the freshest context anyone has on it. +- **It is small and self-contained**, as a tie-break only. Size breaks a tie between two issues of + equal value, and it never promotes a trivial issue over a real defect. + +An issue carrying the `handoff` label is not ranked and is not counted. It is a link in the session +handoff chain `AGENTS.md` "Session Scope" defines, so it records work to do next rather than work of +its own, and an open one is present by design for as long as that chain is in use. Counting it +inflates the number this run reports as the backlog by one for every lane in use, and a backlog +count this fleet reports wrong is a failure with its own history, so filter the label out of the +ranking and out of every count of the open backlog rather than out of the ranking alone. + +An issue carrying the `blocked` label is counted and is not ranked while its blocker stands. It +records real work this repository owes, which is why it stays in the count, and the label says the +work cannot start yet, per `GOVERNANCE.md` "Durable Knowledge and Self-Improvement". So until the +blocker clears it has no group, no worker, and no claim, and spends none of the round's four worker +slots. Whether the blocker still stands is read from what its body names rather than from the label, +since the label comes off by hand and lags the fix, and a fix merged into `develop` leaves the issue +it fixes open, per "Grouping and File Claims" below on closing keywords. Where that read cannot be +made, from a private or deleted repository or a reference nothing can be read from, the blocker +stands rather than being assumed cleared. The round's report names every issue it held back this way +and the blocker each one waits on, since the maintainer reads the report rather than the issue +bodies, and a stuck issue nobody names reads as ordinary backlog that simply never moves. + +An issue that asks a question rather than states a defect is not ranked and is never guessed at. +It has no group, no worker, and no claim, so nothing in "Raising a Blocked Question" applies to it +except how the question travels. It goes to the maintainer at the end of ranking, per +`GOVERNANCE.md` "Communicating with the User", batched with any other question the run is sending +at that moment and in a prompt of its own otherwise, rather than waiting for a stop that may not +come. It stays unranked until answered. + +## Grouping and File Claims + +Group so that **no file is claimed by two live groups at once.** This is the rule the skill exists +for, and it binds harder than any throughput target. + +- **Group by the files a fix will touch**, not by the issues' subject matter. Two issues that read + as unrelated but both edit a shared governance file are one group. Two issues that read as near + duplicates but touch different files are two groups. +- **Genuine duplicates are one group.** Never close an issue during triage on the orchestrator's + own judgment. Comment to cross-link the pair, and let the fix close both. +- **Closing keywords go on the promotion pull request**, not the feature pull request, per + `branching-and-release-model`. A feature pull request merging into develop fires no + auto-close, so a `Fixes #N` line there closes nothing. **The feature pull request body instead + carries a line reading `Closes on promotion: #N`**, listing every issue that pull request + actually fixes and nothing it merely mentions, which is the line the promotion body is assembled + from, per "The Promotion Boundary". Without that line nothing records which issues a merged pull + request closes, since the closing keyword is deliberately absent and a body's other issue + references are not the same set. +- **Predict each group's file set** by reading the issues, not by guessing from their titles. +- **Record every claim on the issue itself**, as a comment naming the predicted file set **and the + branch that holds it**, before dispatching. The branch name is what lets a later session walk + from a worktree it found back to the claim explaining it, which is the direction the next bullet + actually travels. Working notes do not survive the session, so a claim living only in them is + invisible to the round that has to respect it, and recording it on the issue is what makes the + next bullet a read of durable state rather than of the orchestrator's memory. +- **Verify the prediction before dispatching**, against everything in flight, which is wider than + this round: the files changed by every open **feature** pull request on this repository (`gh pr + diff --name-only` per open pull request), and the claim comments of every group still + holding a branch, parked groups from earlier rounds included. `git worktree list` reports the + registered worktrees and the branch checked out in each, which is not the same as every branch + that exists, so pair it with `git branch -r`, after `git fetch --prune origin`, for one that was + pushed and whose worktree is already gone, and with `git branch` for one that was never pushed + and whose worktree is already gone. That third read is not optional here: the worktree-only + disposition retires a tree and leaves its branch standing, so this skill produces exactly that + state, and a local branch holding commits no remote has is invisible to both other reads. Prune + rather than plain fetch because `--prune` is what drops a remote-tracking ref whose branch is + gone from the remote, deleted there by another session or through the web interface, and a + plain fetch leaves that ref in `git branch -r` to defer valid groups forever. Stop and report a + failed fetch rather than reading `git branch -r` anyway, per `GOVERNANCE.md` "Verification + Discipline" on what a local clone answers for: here the scan would miss a branch pushed since + the last successful fetch and keep one deleted since it. The round + stops there and reports, rather than dispatching against a stale answer, and stopping rather + than deferring is what the cleanup and promotion steps need too, since both read the same + remote. + Those three enumerate the branches, with one gap: a branch in the standalone clone `repo-worktree` allows as a fallback is + reached only once it is pushed, since `git branch -r` inventories the remote rather than this + repository's checkouts. The claim comments are what say which files each branch holds, since a branch name says nothing about a file set, an unpushed branch has no + pull request diff to read, and no diff of any branch reports the predicted set a claim records + before the work is committed. **Exclude the open promotion pull request from that enumeration.** + Its diff is all of `origin/main..origin/develop`, so counting it claims nearly every file any + earlier round touched, and a round run during the freeze would defer every group it formed. A + predicted claim that collides with a real one is a group deferred to the next round, never one + dispatched hoping the overlap stays small. +- **A branch no claim comment covers still has to yield a file set.** The maintainer's own + worktree and a hand-driven task's branch are both enumerated above and neither carries a claim + comment, so reading only the comments records them as holding nothing, which is the collision + this section exists to prevent rather than the absence of one. Read the branch itself instead: + `git diff --name-only origin/develop...` for what it has committed, and, for a + registered worktree, `git -C status --porcelain` for what it holds uncommitted. Where + neither read is available the set is unknown rather than empty, and an unknown set collides with + every group, so ask the maintainer what that branch holds per "Raising a Blocked Question". +- **Re-verify when a worker reports that its real file set grew** beyond its claim. A worker + needing a file another group holds stops and reports rather than editing it, and the + orchestrator decides which group keeps the file, then tells the loser which of three things to + do rather than leaving it to choose: narrow its change to drop that file, park until the holder + merges, or abandon its branch and return the issue to the next round's ranking. Where the loser + has already opened a pull request, say whether it closes or waits, since one left open on an + abandoned branch reads to every later round as a live claim. **Update the claim comment whenever + the adjudication changes what a group holds**, in either direction, or the durable record drifts + from the real claim it exists to report. +- **Cap the round at four workers**, whatever the grouping allows. + +## Bounding a Prose Group + +A group whose files are prose-heavy Markdown bloats in a way a code group does not, and it needs +its own bound stated in the worker's brief. + +- **The change stays inside the units the issue names.** Rewording an adjacent section because it + now reads inconsistently is the next issue, filed, not this one's diff. +- **Deleting a claim beats qualifying it.** Where a review disproves something a rule leaned on, + remove the rule that leaned on it. A narrowed qualifier is where a new false claim gets + introduced, and it is the most common way a prose round produces the finding the following round + then fixes. +- **The review-round budget is `local-strict-review` "Disposing of Findings"'s.** The brief names + it and states no second one. + +## Dispatching a Worker + +Brief on `AGENTS.md` "Context and Delegation Discipline"'s subagent shape. + +- **The worker drives its group to a develop merge**, by invoking `drive-pr` with the target + stated as develop only. That skill owns the review loop, the finding disposition, and the merge, + so brief the group and the bounds rather than restating the loop. +- **The worker creates its own worktree**, always, as `drive-pr`'s worktree isolation and + `repo-worktree`'s task-start mandate already require of the task itself. No worker inherits another's worktree, + which is why "Bounding the Wait on a Worker" either removes a dead worker's tree and its + branch or leaves that tree untouched for the maintainer, and never passes it on. +- **The worker does no cleanup**, which is this skill's one stated override of `drive-pr`'s + post-merge cleanup and of `repo-worktree`'s post-merge procedure. Say so in the brief, because + a worker following either alone will clean up. The worker still performs the merge itself, and + what the override moves is the two cleanup halves `drive-pr` runs after it, the worktree + procedure and the verify-then-delete of the merged remote branch, **both** rather than only the + first. "Cleanup Is the Orchestrator's" below, in this + same section, says why and what it covers. +- **The worker runs `local-strict-review` before every push**, per `GOVERNANCE.md` "Verification + Discipline". That pass dispatches a reviewer of its own, so a harness where a subagent cannot + dispatch one leaves the worker unable to run it and unable to push. It reports that rather than + pushing, and its worktree is then retired, since git refuses to attach that branch anywhere else + while the reporting tree holds it. The branch is left standing for its own reason, that the + commits it already carries are what the re-dispatched worker continues from. This is the + worktree-only disposition "Cleanup Is the Orchestrator's" separates out, so a clean tree is the + whole test. A clean + tree is retired and the group re-dispatched to a seat that can dispatch. A dirty one is left + exactly as it stands and the group stopped for the maintainer, as is a group for which no seat + that can dispatch exists. That retire-and-re-dispatch case presumes the branch is reachable from + this repository, which the standalone clone `repo-worktree` allows as a fallback breaks: a worker that never + pushed holds its commits only in that clone, where this repository has no ref to hand a + replacement and nothing to retire, so re-dispatching loses the work rather than continuing it. + That group stops for the maintainer with the clone named, and no seat this skill defines resumes + it, since a worker never inherits another's checkout and the orchestrator opens no branch and + edits nothing. Neither the worker nor the orchestrator pushes around the missing pass. +- **The brief names the branch the worker will use**, which is what lets the claim comment record + it before dispatch. The worker still creates its own worktree, on that named branch rather than + one of its choosing, since a claim naming a branch nobody used points at nothing. +- **The brief requires the `Closes on promotion:` line** in the pull request body, listing exactly + the issues this group fixes. A worker never reads this skill, and `drive-pr` does not ask for the + line, so a brief that omits it produces a pull request nothing can derive a closing set from. +- **The brief names the files this group owns and the files it must not touch.** A subagent never + reads this skill, so a claim it was never given is a claim it cannot respect. State this group's + claimed set, state that any other group's file is out of bounds, and state the duty that makes + the re-verification path work: a worker needing a file outside its claim stops and reports rather + than editing it, and waits for the orchestrator to adjudicate. +- **The worker never merges to main** and never resolves a thread it did not actually dispose of. + +### Cleanup Is the Orchestrator's + +`repo-worktree`'s post-merge procedure returns the base clone to current develop before proving +the cleanup, and `branching-and-release-model` states that requirement independently. Four workers doing +that concurrently mutate one shared checkout, which `GOVERNANCE.md` "Repository Boundaries and +Write Safety" forbids. A worker also cannot +finish the procedure from inside its own worktree, since removing that worktree leaves it with no +working directory in which to delete its branch. + +So the whole procedure moves to the orchestrator, which runs it from the base clone at the round's +cleanup step, while no worker is live in a tree it touches. It carries the remote half of `drive-pr`'s +post-merge cleanup too, verifying the merged branch's tip against the pull request's `headRefOid` before +`git push origin --delete`, since taking that step from the worker without naming a new owner +would leave a live remote branch behind every group. It covers every group that is done with its tree, +which is the finished ones **and the abandoned ones**: a group told to abandon its branch keeps a +registered worktree until something removes it, and that worktree holds a live claim that would +collide with the very group the next round re-forms for the same issue. For a merged group the +procedure is deferred rather than changed: `repo-worktree`'s verify-before-removing and +prove-the-cleanup steps run unchanged, just later and in one seat. + +**Retiring a worktree and deleting its branch are two dispositions with two tests**, and citing +one for the other is how a removal that discards nothing gets routed to the maintainer, or a +removal that discards commits gets waved through. Retiring a worktree alone, the branch left +standing, risks only what is uncommitted in it: a clean tree is the whole test, the branch's own +contents do not enter it, and a tree that is not clean is left exactly as it stands while the +group goes to the maintainer per "Raising a Blocked Question". Deleting the branch as well risks what is committed, so it carries +whichever branch check the group's state calls for, `repo-worktree`'s verify-before-removing for a +group whose pull request merged and the no-merge substitute below for one whose has not. Which +check that is matters: a squash merge never makes the feature tip an ancestor of develop, so the +substitute would fail on every merged group if it were read as covering them. Every disposition in +this skill names which of the two it is. + +An abandoned group, and a dead worker's clean tree, have no merged pull request for +`repo-worktree`'s verify-before-removing step to read, so the check that step gives way to here is +what it exists to establish, that nothing unmerged is +being thrown away: confirm the branch carries no commit that is not already on develop, and that +its worktree is clean. Both hold, and the worktree and branch go the same way a merged group's do, +with no remote branch to delete where none was pushed. Either fails, and cleanup stops there and +the group goes to the maintainer per "Raising a Blocked Question", since past that point removal +discards work. A worker still live, a promotion fix included, keeps its worktree until the next +round's cleanup step. + +### Choosing the Worker's Model Tier + +`AGENTS.md` "Delegation" owns the model-tier rule. Read it there. This is only what it leaves to +judgment here: the tier is chosen per group rather than defaulted, because a stronger tier +produces better work up front and takes fewer review rounds to land it, which often costs less +than a cheaper worker looping. Three kinds of group are never tiered down: + +- One touching **carried canonical content**, as `GOVERNANCE.md` "Verification Discipline" bounds + it, since a wrong rule propagates to every carrier. +- One touching **anything `AGENTS.md` "Delegation" calls a design change**, however small the diff + looks. +- One whose issues are **complex or entangled**, where the fix depends on reasoning across several + files or on a contract not stated in the file being edited. + +State the chosen tier and its reason in the round's report. + +## Bounding the Wait on a Worker + +`AGENTS.md` "Delegation" binds this wait as it binds any other, and this section is how the bound +is met here. A worker +reports merged, parked, or stopped. A worker that reports nothing at all is the case needing a +bound, since it is indistinguishable from a slow one and dying mid-drive is ordinary here. + +The bound is a state read rather than a clock: when the other workers in the round have reported, +read the silent worker's branch and pull request directly, `git log` on that branch and +`gh pr view `, passing the branch as the positional argument that command takes, since a +bare `gh pr view` resolves the pull request of whatever branch the caller is standing on and never +the worker's. Let what they show decide. A pull request that is merged, or a branch whose work is +complete, means the worker died after doing the work and the group is finished. + +Anything else needs one thing established before anything is touched: whether that worker is gone +or merely slow. No git read answers that, and the two call for opposite actions, so the answer +comes from the dispatch mechanism itself, which knows whether the subagent it started is still +running. Nothing about the worktree is acted on while the answer is "still running", however long +that is. Waiting costs a round's latency and guessing costs another task's uncommitted work. + +Once the worker is confirmed gone, its worktree decides what follows. **A clean one** is cleaned up +as an abandoned group's is, per "Cleanup Is the Orchestrator's", which confirms the branch carries +no commit that is not already on develop before anything is removed. A worker that committed its +fix and then died leaves a clean tree standing over commits develop has never seen, so that check +is what separates the two. It holds, and the issue returns to the next round's ranking to be +dispatched fresh, its claim comment released with the worktree. It fails, and cleanup stops there +and the group goes to the maintainer, since past that point removal discards work. **A dirty one is left exactly as it stands** +and the group is stopped for the maintainer per "Raising a Blocked Question", naming the worktree +and what is uncommitted in it. The orchestrator does not commit that work, hand the tree to a +replacement to commit, or remove it, per `GOVERNANCE.md` "Repository Boundaries and Write Safety". +Where no other worker remains to bound the wait, the same liveness answer bounds it alone. + +## Raising a Blocked Question + +A group reaching a question only the maintainer can answer stops that group and nothing else. +`pr-review-conduct`'s "Escalate to the maintainer when" list is what makes a finding a question +rather than a decision. + +- **The group stops, and nothing about it is disposed of.** No thread is resolved, no finding is + answered on the orchestrator's own judgment, and no pull request merges. +- **The other groups keep driving.** One stopped group never idles the round. +- **The question travels worker to orchestrator to maintainer, and is asked when the group + stops.** A worker escalates to whoever dispatched it, per `pr-review-conduct`, since a + dispatched subagent is not the seat that can prompt anyone. The orchestrator is that seat, and + it asks then and there, per `GOVERNANCE.md` "Communicating with the User". Holding the question + for a round boundary is what that section forbids, and a boundary can be a long way off or, for + a group blocking the promotion pull request, never arrive at all. Where several groups stop + close together, their questions go in one prompt, which is batching without deferral. +- **The question is also written on its issue**, so it survives the session that asked it. +- **A stopped group keeps its branch and its claim**, and its worktree is left exactly as it + stands while the question is open, since the answer may be that the work in it continues. Say in + the prompt whether that worktree holds uncommitted work, because what becomes of it is part of + what is being asked rather than something to settle while waiting. +- **Resuming retires that worktree first, then dispatches a fresh worker.** Git refuses to attach a + branch already checked out somewhere, per `repo-worktree`, so a fresh worker cannot take the + branch while the stopped tree holds it. Once the answer is in, remove that worktree if it is + clean, or apply what the answer said about its uncommitted work and then remove it, and only then + dispatch. This is the same retire-then-dispatch shape "Bounding the Wait on a Worker" uses, and + no worker ever inherits another's tree. + +## The Promotion Boundary + +Each round ends with at most one develop -> main promotion pull request, driven to green and left +for the maintainer, so that one carries a single round rather than accumulating several. + +**This section assumes the release workflow model**, where feature work reaches develop through +squash-merged pull requests and a promotion pull request carries develop to main. A repository +whose registry `workflowModel` reads `operational` reaches develop differently, per `GOVERNANCE.md` +"Operational Repositories". Confirm with the maintainer whether a promotion pull request per round +is wanted there. + +That difference changes nothing about how this run's own work is read. Every worker invokes +`drive-pr` whatever the model, so this run's fixes still arrive as squash-merged feature pull +requests carrying the `Closes on promotion:` line, and the two hops still read them. What the +operational model adds is a second kind of commit in the same range, a direct push to develop that +never had a pull request, whose issues are recoverable only from the commit message itself. Read both, the pull requests for this +run's work and the commit messages for the direct pushes, since reading either alone returns a +partial set, and the range rather than this round is still what covers earlier work no promotion +has carried. + +1. **Open it whenever develop is ahead of main**, which `git fetch origin` and then + `git rev-list --count origin/main..origin/develop` answers, and this round's own outcome does + not. Fetch first every time: a stale remote-tracking ref reports zero and the round would report + nothing to promote while develop carries work. A round in which every group deferred or parked + can still owe a promotion pull request, for work an earlier round landed and no promotion has + yet carried. A count of zero is the only case with nothing to promote, and the round reports + that instead of attempting one. +2. Drive its review loop per the promotion half of `drive-pr` "The Drive Loop", **with a + review-round budget set before the first round**. That loop repeats until the promotion pull + request meets every `pr-review-conduct` Merge Gate item except the maintainer's explicit + permission to merge, and nothing in that loop terminates on its own, so when the budget is + reached, stop and put the state to the maintainer rather than continuing to spend the run's only + forward gear on one pull request. +3. Put the ready pull request to the maintainer, per `GOVERNANCE.md` "Communicating with the + User", with its merge as the action asked for. The maintainer's merge is the run's clock, so one + reported in a closing paragraph and never actually asked about stalls every round behind it. + Do not merge it. +4. **While it waits, develop takes only what that pull request itself needs.** A finding against + it lands as its own feature -> develop pass, and that landing moving its head is expected, since + its head **is** develop. **That pass is dispatched as a worker like any other**, which is the + one push the freeze permits and the reason the orchestrator still opens no branch of its own. + `drive-pr` "The Drive Loop" sends the seat driving a promotion pull request back through its + own feature -> develop pass for such a fix, and here that seat dispatches rather than drives it. +5. **A promotion fix outranks any file claim.** A group holding a file it needs yields, because the + promotion pull request is what the whole run is queued behind. A holder that is merely parked + yields by handing the file over. A holder that already pushed and has an open pull request + yields by having that pull request wait, its branch untouched, and by the promotion fix taking + the file, since the two must not be in flight on one file at once. Once the fix lands, that + pull request waits untouched until the freeze lifts. Reconciling its content is the next round's + worker's job rather than the orchestrator's, which opens no branch and edits nothing. The + orchestrator retires that group's worktree, the branch and its pull request left standing, + which is the worktree-only disposition "Cleanup Is the Orchestrator's" separates out and the + retire-then-dispatch shape "Raising a Blocked Question" uses, and then dispatches a fresh + worker on that same branch, briefed either to merge develop in to pick the fix up or to narrow + the change to drop the file. Never rebase it, + since its branch is already pushed and a rebase there needs what `GOVERNANCE.md` "Git and Commit + Rules" forbids. +6. **Nothing else pushes, and nothing else is dispatched.** The promotion fix of step 4 is the one + exception to both, and everything in this step is said of the next round's work rather than of + it. That round's preparation is orchestrator work and continues: rank, group, and verify claims. + Its dispatch waits, because a worker has exactly one procedure, `drive-pr`, which + pushes and opens a pull request, so a next-round worker dispatched under the freeze would either + break it or sit in a state that procedure does not describe. None is left running across the + wait either, since a worker held idle for an unbounded maintainer wait is one doing nothing at a + cost, and dispatching it after the merge starts it against the state that merge produced rather + than the state it was briefed on. +7. The merge unfreezes the run, and the prepared round dispatches then. + +The run advances no faster than the maintainer merges promotion pull requests. That is the human +gate, stated plainly rather than left for a stalled round to reveal. + +### Assembling the Promotion Body + +The body carries one `Fixes #N` per issue whose fix is on develop and not yet on main. Two hops +are needed rather than one, because the commits in `origin/main..origin/develop` are squash merges +whose subjects carry the **pull request** number and not the issue number, and this skill +deliberately keeps the closing keyword off the feature pull request, so nothing in the range names +an issue directly. Read the pull request numbers out of that range, freshly fetched, then read each +of those pull requests for its `Closes on promotion:` line, the one "Grouping and File Claims" +requires every feature pull request to carry and every worker brief to ask for. + +**That line exists because the set has to be stated rather than inferred**, distinct from any +issue a body merely mentions. A body routinely references an issue it +does not fix, the deferral issues its own review round filed most of all, and those have to stay +open as the next round's ranking input. Sweeping in everything a body mentions would close them at +the promotion merge and delete the next round's backlog, so the promotion body reads the explicit +line and never the mentions. An issue named nowhere is one nothing closes, which is a missed +closure a later round notices, where the opposite error destroys work. + +Deriving the set from the range rather than from what this round dispatched is what covers a group +that deferred or parked, contributing none, and an earlier round's work that no promotion has yet +carried. A fix landing during the freeze adds its issue to a body already written, so amend the +body when it lands rather than leaving the issue to be closed by hand. + +## Run State + +- **Working notes outside the repository hold the round**: the ranking, the working groups, the + tier choices, and the worker assignments. A scratch file the harness gives a session serves + where there is one, and any note kept out of the tree serves where there is not. It is the + in-flight session state `GOVERNANCE.md` "Durable Knowledge and Self-Improvement" describes, and + nothing about it is committed. +- **GitHub holds what outlives the session.** A claim comment records a group's file set, a pull + request body records what a round carried, a `Fixes #N` line records what the promotion closes, a + deferral issue records what was put off and why, a thread reply records how a finding was + disposed of, and a stopped group's question is a comment on its issue. +- **No tracker file is committed for the run.** A committed tracker is a file every round rewrites, + which is the contention the file-claim rule exists to prevent. + +## Ending the Run + +The run ends at either of two points, and they are different endings. + +- **The backlog is worked out**, meaning a full re-rank finds no open issue this skill can act on. + That is not the same as zero open issues, since a backlog of nothing but maintainer questions and + issues whose blockers still stand is a finished run. Report it as finished, with the questions put to the maintainer. +- **The session ends**, for a context limit or because the maintainer stops it. The run ends with + it, since the merge authorization was bounded to that session. What the rounds already landed + stands on its own in GitHub, and the branches, claim comments, and questions left behind are + what a later run reads to pick the work up. That later run is a new run, named again, not this + one continuing. + +Report at every round boundary and at either ending: what merged to develop, what the promotion pull +request carries, what was newly filed, what is stopped and on which question, what was held back and +on which blocker, and what the next round would pick. + +## Mechanics Live Elsewhere + +- The review loop, the Merge Gate, the five finding outcomes, and when a finding is a question: + `pr-review-conduct`. +- Driving one pull request, and the promotion-pull-request wrinkle: `drive-pr`. +- Worktree isolation, the base branch, and the cleanup procedure this skill re-seats: + `repo-worktree`. +- Closing keywords, branch protection, and the promotion trap: `branching-and-release-model`. +- The pre-push adversarial pass and its recorded receipt: `local-strict-review`. +- Merging the promotion pull request and dispatching a release: `merge-and-release`, invoked + separately. +- Delegation briefing shape, model-tier rules, wait discipline, and session scope: `AGENTS.md` + "Context and Delegation Discipline". diff --git a/.github/skills/operational-vs-release-workflow/SKILL.md b/.github/skills/branching-and-release-model/SKILL.md similarity index 86% rename from .github/skills/operational-vs-release-workflow/SKILL.md rename to .github/skills/branching-and-release-model/SKILL.md index 9a9e9f2..c8bfac9 100644 --- a/.github/skills/operational-vs-release-workflow/SKILL.md +++ b/.github/skills/branching-and-release-model/SKILL.md @@ -1,24 +1,22 @@ --- -name: operational-vs-release-workflow +name: branching-and-release-model description: >- - Governs how a ptr727/ProjectTemplate fleet repo branches, promotes, and publishes: the - feature -> develop -> main flow, squash-only vs. merge-commit-only branch protection, the two - develop -> main promotion traps (never delete develop, EOL-only conflicts), the two-phase - publish model (PRs smoke-test only, a human merge never auto-publishes), NBGV semantic - versioning, and the operational-repo delta (direct-to-develop commits, advisory CI, dispatch-only - release) that applies instead whenever the registry's workflowModel field for this repo reads - operational rather than release. Use this whenever choosing a target branch for a change, - promoting develop to main, resolving a develop -> main merge conflict, deciding whether a - release repo's config change needs a PR versus an operational repo's config change can commit - straight to develop, bumping version.json, adding or dropping a release target, or reasoning - about why a merge did or didn't trigger a publish. Triggers even when the request sounds like - ordinary git housekeeping ("just push this config fix", "merge develop into main", "cut a - release"), because the two workflow models genuinely differ (a direct-to-develop commit that is - correct in an operational repo is a rule violation in a release repo, and vice versa) and - applying the wrong one is not obviously wrong to a reader who only knows one of the two. + Governs how a ptr727/ProjectTemplate fleet repo branches, promotes, and publishes: the feature + -> develop -> main flow, squash-only vs. merge-commit-only branch protection, the two promotion + traps (never delete develop, EOL-only conflicts), the two-phase publish model (PRs smoke-test + only, a human merge never auto-publishes), NBGV versioning, and the operational-repo delta + (direct-to-develop commits, advisory CI, dispatch-only release) that applies whenever the + registry's workflowModel reads operational. Use this whenever choosing a target branch, + promoting develop to main, resolving a promotion conflict, deciding whether a config change + needs a PR or can commit straight to develop, bumping version.json, adding or dropping a release + target, or reasoning about why a merge did or didn't publish. Triggers even when the request + sounds like ordinary git housekeeping ("just push this config fix", "merge develop into main"), + because the two workflow models genuinely differ and a step correct in one is a rule violation + in the other. This is the git policy: `workflow-ci-contract` keeps the YAML half, and performing + a promotion merge or a release dispatch is `merge-and-release`, which wins where both fire. --- -# Operational vs. Release Workflow +# Branching and Release Model ## Why this exists @@ -104,10 +102,15 @@ rather than guessing from the repo's contents. `HISTORY.md`, and release notes name the version as `Version 1.0` (the floor), never the concrete build height, which is both wrong (the real height differs) and a maintenance trap. "Correcting" `1.0` to `1.0.0` is a defect. -- **A no-op publish (unchanged NBGV `SemVer2`) re-pushes nothing to any target keyed on the - version string, except Docker, which always re-pushes** to pick up upstream base-image +- **A no-op publish on a schedule or push trigger (unchanged NBGV `SemVer2`) re-pushes nothing to + any target keyed on the version string, except Docker, which always re-pushes** (a dispatch + refreshes the release instead of skipping) to pick up upstream base-image refreshes. Full guarantee and the `version.json` `pathFilters` boundary: `references/release-publish-mechanics.md`. +- **A package push can fail after the release is already cut**, since it runs after the release + task and no gate covers it. A full re-run is always available inside its bounded + window and is the only route once the branch tip has moved: + `references/release-publish-mechanics.md`. - **Adding, dropping, or wiring a release target** (which leaf task, which artifact-naming contract, which seam a given output belongs to: a GitHub Release asset, a package-registry push, an image-registry push, a filesystem deploy, or a source-only repo with no build layer at all), diff --git a/.github/skills/operational-vs-release-workflow/references/branch-protection-and-promotion.md b/.github/skills/branching-and-release-model/references/branch-protection-and-promotion.md similarity index 94% rename from .github/skills/operational-vs-release-workflow/references/branch-protection-and-promotion.md rename to .github/skills/branching-and-release-model/references/branch-protection-and-promotion.md index 6d934c0..9ca2276 100644 --- a/.github/skills/operational-vs-release-workflow/references/branch-protection-and-promotion.md +++ b/.github/skills/branching-and-release-model/references/branch-protection-and-promotion.md @@ -14,16 +14,15 @@ and `main`** from the hub's `repo-config/*.json` payloads. Run `repo-config/configure.sh apply / release|operational` from that checkout. The names are load-bearing because governance content and workflows reference them. The registry `workflowModel` selects the `develop` payload for a registered repository. Pass the model -explicitly for a repository outside the registry. See the hub's `repo-config/README.md` -"Rulesets" for the configured state. +explicitly for a repository outside the registry. ## Executing a `develop -> main` promotion safely Two traps, both learned the hard way: - **Never delete `develop`.** A promotion PR's head *is* `develop`, so `gh pr merge --delete-branch` - (and a repo's "Automatically delete head branches" toggle, kept off in the hub's - `repo-config/settings.json` for exactly this reason) deletes `develop` itself. Merge a promotion + (and a repository's "Automatically delete head branches" toggle, which the fleet keeps off for + exactly this reason) deletes `develop` itself. Merge a promotion with a plain `gh pr merge --merge`, no `--delete-branch`. If `develop` is ever lost this way, restore it to the merged PR's head SHA, which is still reachable as the merge commit's second parent: `gh api -X POST "repos///git/refs" -f ref=refs/heads/develop -f sha="$(gh pr view --json headRefOid --jq .headRefOid)"`. @@ -106,5 +105,6 @@ supplies its own input-deterministic generator and wires the codegen reference w `actions/create-github-app-token` deprecated the numeric `app-id` input in v3.0.0. Use `client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}`. When adding new App-token call sites, use the -same form, and do not reintroduce `app-id` / `CODEGEN_APP_ID`. See the hub's -`repo-config/README.md` "Secrets" for which secrets each mechanism needs. +same form, and do not reintroduce `app-id` / `CODEGEN_APP_ID`. A mechanism needing a secret this +repository does not hold is a configuration question for the maintainer rather than something to +work around in the workflow. diff --git a/.github/skills/operational-vs-release-workflow/references/release-publish-mechanics.md b/.github/skills/branching-and-release-model/references/release-publish-mechanics.md similarity index 60% rename from .github/skills/operational-vs-release-workflow/references/release-publish-mechanics.md rename to .github/skills/branching-and-release-model/references/release-publish-mechanics.md index a95b734..fb6080c 100644 --- a/.github/skills/operational-vs-release-workflow/references/release-publish-mechanics.md +++ b/.github/skills/branching-and-release-model/references/release-publish-mechanics.md @@ -2,8 +2,9 @@ Full detail for the "Publishing" rules in `SKILL.md`. Load this when adding or removing a release target, wiring a new leaf build task, deciding where a build output belongs (a GitHub Release -asset, a package-registry push, an image push, a deploy), or setting up a wrapper repo that tracks -an upstream release, not for reading the release model's shape (the SKILL.md summary covers that). +asset, a package-registry push, an image push, a deploy), recovering a package push that failed +after the release was already cut, or setting up a wrapper repo that tracks an upstream release, +not for reading the release model's shape (the SKILL.md summary covers that). ## Reusable-task parameter contract @@ -29,35 +30,13 @@ are target-agnostic. ## Orchestration vs. build: the override seam -The pipeline splits into two layers. The **orchestration** layer is generic and is the -standardization baseline: `publish-release.yml` (single-branch publish plan), the `get-version` -task plus `github-release` job inside `build-release-task.yml`, `get-version-task.yml`, and the -aggregator shape of `test-pull-request.yml`. Within -`test-pull-request.yml`, only the `changes -> smoke-build -> check-workflow-status` aggregator -wiring and the ruleset-bound job name are verbatim orchestration, while the `unit-test` job and -the `dorny/paths-filter` entries are owned/per-target. The **build** layer is a hook: a composite -action at `.github/actions/build-` the hub-hosted `build-release-task.yml` reaches. The -hub defaults require explicit project paths. A project needing more than a path override carries -its own hook. - -The contract that keeps the seam clean: **a target contributes files to the GitHub release by -uploading a workflow artifact named `release-asset--`.** The `github-release` job -collects every `release-asset--*` artifact by pattern, so its `download-artifact` step -uses `pattern:`/`merge-multiple:`, **never an `artifact-ids:` that names a build job's output** -(the producing build jobs still appear in `needs` for sequencing). That makes the tag-the-commit -plus create-the-release plus attach-the-assets logic reusable **as-is** across repos. **This -name-pattern handoff is canonical for every repo, single-target included**: name your one asset -`release-asset--` and the verbatim `github-release` globs it. Do not switch a -single-target repo to an `artifact-id` output plus `download-artifact` `artifact-ids:`, which -looks tidier for 1:1 but forks the `github-release` download and breaks its verbatim carry. - -**What a repo still curates** (by design, not a leak): which `enable_` inputs its caller -stub sets, per the per-target subsetting rule above. `build-release-task.yml` is hub-hosted -(`docs/reusable-workflows.md` "Stage 4: The Release Chain and the Docker Core"), so its job graph -and its `github-release` job are the hub's, not a per-repo file a caller edits. A repo adopting the -release chain carries only the caller stub in its own `publish-release.yml` and -`test-pull-request.yml`, naming the hub task by pin and setting the `enable_*`, `docker_image`, -and project-path inputs its targets need. +The split between the generic **orchestration** layer and the repo-owned **build** layer, the +`release-asset--` pattern handoff that keeps the seam between them clean, and +what a repo curates when it adds or drops a target are `WORKFLOW.md` section 3's, under "Two +Layers: Orchestration vs Build" and "The Seam Contract", which the `workflow-ci-contract` skill +carries whole as its `references/architecture.md`. A caller of the hub-hosted +`build-release-task.yml` also sets the inputs its enabled targets need, `docker_image` and the +project-path inputs among them. ## Map your outputs to the right seam @@ -116,9 +95,9 @@ version/tag, which is heavyweight but expected even for a non-.NET repo, and acc ## No-op republish guarantee -A weekly/dispatch publish where NBGV `SemVer2` is **unchanged** (no new commit since the last +A scheduled or push publish where NBGV `SemVer2` is **unchanged** (no new commit since the last publish) re-pushes **nothing** to GitHub Releases (the `github-release` job's `release-exists` -check skips the create step), NuGet (`dotnet nuget push --skip-duplicate`), or PyPI +check skips the create step, and a dispatch refreshes the release instead of skipping), NuGet (`dotnet nuget push --skip-duplicate`), or PyPI (`gh-action-pypi-publish` `skip-existing: true`), since all three key on the version string. **Docker always re-pushes** by design: it picks up upstream base-image refreshes (e.g. `ubuntu:rolling`) that aren't visible in the repo. Boundary: `version.json` has **no @@ -127,6 +106,26 @@ NBGV git height and therefore `SemVer2`, and the next publish *does* create a fr even when the shipped binary is byte-identical. This is accepted NBGV behavior, and `pathFilters` are intentionally not added. +## Recovering a failed registry push + +A package publish job is gated like everything else, `needs:` the release-task call, so a failed build skips it. The **push inside it** is what no gate can reach, because it runs after the whole release task and therefore after `github-release`. `WORKFLOW.md` D4.5 names the two recovery routes and leaves their mechanics here. A rejected token exchange, a registry outage, or a trusted-publishing policy naming the wrong workflow file leaves a published release and tag for a version that never reached the registry. The recovery is a re-dispatch or a full re-run rather than a cleanup. **A full re-run is always available inside its window, and a re-dispatch only while the branch tip has not moved**, so the tip decides whether there is a choice at all rather than which route to take. What re-dispatch buys, where it is available, is that it outlives the re-run window. + +**Re-dispatch, available only while the tip has not moved.** A `workflow_dispatch` takes a ref rather than a commit, and D2.3 admits only `main` or `develop`, so what it builds is that branch's tip at dispatch time. While the tip is still the commit whose push failed, a re-dispatch rebuilds the same version and runs its push again, refreshing the release the way any dispatch does. + +This is a time-of-check-to-time-of-use race rather than a guarded operation: nothing compares the tip against the failed run, so a push landing between the two mints a new version instead of erroring, and the operator sees a green publish that left the failed version unpublished. Confirm the failed run's own head commit still equals the branch tip immediately before dispatching, reading it as `gh run view --json headSha` against `gh api repos/{owner}/{repo}/branches/` for the branch that run built rather than whichever branch is to hand. Where the two differ, or where the check is not worth making, prefer the re-run route, which is bound to that commit by construction, and fall back to re-dispatch only once the re-run window below has closed. + +**Re-run all jobs, available inside the window whatever the tip has done.** `gh run rerun ` replays the run under the original event's `GITHUB_SHA` and `GITHUB_REF` and re-executes every job rather than only the failed ones. The publisher pins the release task to that commit with `ref: ${{ github.sha }}`, so `get-version` recomputes the same version from the same commit and history, each build leaf checks out the `GitCommitId` that job emits, the package artifact D5.2 deleted is rebuilt and re-uploaded rather than missing when `publish-` downloads it, and that job retries the push it failed. The release itself needs nothing from the re-run, the failed run having already cut it, though on a dispatch-triggered run the re-run re-enters `github-release`, which refreshes the release per D4.4's dispatch leg and runs the `release-asset-*` delete with it per D5.2. A re-dispatch here would build the new tip instead, and NBGV derives the version from git height, so that is a further version and the one whose push failed never reaches the registry. + +Three qualifications come with the re-run route. + +- D4.4 and `WORKFLOW.md` 5B's S9 describe a re-run whose predecessor push **succeeded**, where the registry dedupes the second one. This is the case they do not cover, and its retried push is the first the registry ever receives for that version. +- GitHub offers a re-run only within **30 days** of the initial run, and a repository's own **log** retention setting can be shorter, so the usable window is the shorter of the two. This is the run's own retention and is unrelated to D5.4's `retention-days: 1`, which bounds an uploaded artifact rather than the run. +- **Re-run failed jobs** (`--failed`) does not serve here. D5.2's delete runs on the path that reaches this case, its gate being `!cancelled()` and the download having succeeded, so it has already removed the package artifact a `--failed` re-run would download, and only the full re-run rebuilds it. + +Past the window, a moved tip leaves that version with no route to the registry. The release and tag already name it, and removing them is not the answer: leave them, and let the next publish carry a later version, recording the gap in `HISTORY.md`, since the release body is regenerated on any later dispatch refresh and cannot hold the record. + +What no route settles in advance is whether the registry accepts the retried push. + ## Wrapper repos that track an upstream release A repo wrapping an upstream release uses the hub-hosted `check-upstream-version-task.yml`: a diff --git a/.github/skills/carried-instruction-file-guard/SKILL.md b/.github/skills/carried-instruction-file-guard/SKILL.md index 47f8803..c618722 100644 --- a/.github/skills/carried-instruction-file-guard/SKILL.md +++ b/.github/skills/carried-instruction-file-guard/SKILL.md @@ -1,7 +1,18 @@ --- name: carried-instruction-file-guard description: >- - Stops a blind overwrite of a downstream repo's AGENTS.md, GOVERNANCE.md, CODESTYLE.md, or WORKFLOW.md when resyncing or updating it to match the ptr727/ProjectTemplate hub template. Use this whenever about to edit, replace, re-vendor, or sync-to-match-the-hub any of those four files in a repository that is not ProjectTemplate itself, or whenever asked to bring a repo's instruction set up to date, run a conformance sweep, or fix drift against the hub. Triggers even when the request sounds routine, such as copying the hub's AGENTS.md over or resyncing a repo's docs, because that phrasing is exactly how a real incident happened, where a downstream repo's local rules were silently deleted by a full-file overwrite. Do not skip this just because the task looks mechanical. + Stops a blind overwrite of a downstream repo's AGENTS.md, GOVERNANCE.md, CODESTYLE.md, or + WORKFLOW.md when resyncing or updating it to match the ptr727/ProjectTemplate hub template. Use + this whenever about to edit, replace, re-vendor, or sync-to-match-the-hub any of those four + files in a repository that is not ProjectTemplate itself, or whenever asked to bring a repo's + instruction set up to date, run a conformance sweep that applies fixes, or fix drift against the + hub. Triggers even when the request sounds routine, such as copying the hub's AGENTS.md over or + resyncing a repo's docs, because that phrasing is exactly how a real incident happened, where a + downstream repo's local rules were silently deleted by a full-file overwrite. Do not skip this + just because the task looks mechanical. It applies only when a write is about to happen, so a + read-only `audit-a-repo` run does not fire it, it co-fires with `resync-a-repo` rather than + replacing it, `check-this-repo` never writes these files and escalates instead, and + `.github/copilot-instructions.md` is `copilot-instructions-keeper`'s. --- # Carried Instruction File Guard diff --git a/.github/skills/fleet-conformance-check/SKILL.md b/.github/skills/check-this-repo/SKILL.md similarity index 69% rename from .github/skills/fleet-conformance-check/SKILL.md rename to .github/skills/check-this-repo/SKILL.md index 2b6e840..f16338b 100644 --- a/.github/skills/fleet-conformance-check/SKILL.md +++ b/.github/skills/check-this-repo/SKILL.md @@ -1,5 +1,5 @@ --- -name: fleet-conformance-check +name: check-this-repo description: >- Checks, from inside a downstream repo's own session, whether this repo and this machine are current against the ptr727/ProjectTemplate hub, and safely self-applies what it can. Use this @@ -15,12 +15,12 @@ description: >- out first. --- -# Fleet Conformance Check +# Check This Repo ## Why this exists -A downstream repo today only finds out it has drifted when someone runs a hub-driven resync -against it by name. Nothing notices from the inside on its own. This skill is that inside check, +A downstream repo today only finds out it has drifted when someone runs an audit or a resync +against it. Nothing notices from the inside on its own. This skill is that inside check, run with no hub-side operator watching, so a stale Skills install or an out-of-date `AGENTS.md` pointer gets noticed and fixed without waiting for a fleet-wide sweep to reach this particular repo. @@ -30,9 +30,13 @@ repo. 1. **Is the Skills install current on this machine.** `scripts/` is hub-hosted and reached rather than carried, per GOVERNANCE.md "Hub-Hosted Tooling", so fetch a hub checkout (`github.com/ptr727/ProjectTemplate`, `main` branch, fetched fresh) and run - `python3 scripts/skills_install.py --report` from it. A stale or missing stamp is very often + `python3 scripts/skills_install.py --report` from it. A snapshot not current, or no stamp, is very often the direct answer to "why isn't a fleet rule applying": the harness never loaded the current - content in the first place, and no amount of re-reading `GOVERNANCE.md` fixes that. + content in the first place, and no amount of re-reading `GOVERNANCE.md` fixes that. For a + Claude Code session, read `live` as well, since that channel loads the registered checkout in + place rather than the copy: a checkout that is missing, detached, or on an old branch is an + answer there whatever the exit code says, and moving that checkout is the fix rather than + re-installing. 2. **Does this repo's own carried content still match the hub.** Compare `AGENTS.md`'s "Where the Rules Live" pointer text, and any other verbatim `AGENTS.md`/`GOVERNANCE.md` section this repo carries, against the same hub checkout's current wording, by reading the text rather @@ -40,18 +44,21 @@ repo. ## What it is safe to fix on its own -- **Re-run the installer**, `python3 scripts/skills_install.py`, when the stamp reports stale. +- **Re-run the installer**, `python3 scripts/skills_install.py`, from that same `main` checkout, + when `--report` exits non-zero. This is a per-machine, local-only change, nothing in it touches this repo's git history or needs a review. Nothing else. This skill never re-vendors a carried file, never deletes one, and never applies a -setting or ruleset. Those are `resync-a-repo`'s job, driven from the hub with a named target, -never a downstream repo acting on itself. +setting or ruleset. Converging that drift is a resync, a separate change on its own branch, run +per the hub's `RESYNC.md` by this repo's own session or by `resync-a-repo` from a hub checkout. ## Refresh cadence -Re-run the installer when `--report` exits non-zero, and after any hub merge that touches -`.agents/skills/`. Session entry runs no automatic check, by design: the trigger is suspicion, +Re-run the installer from a hub checkout on a freshly fetched `main` when `--report` exits +non-zero, and after any promotion to `main` that touches `.agents/skills/`. A copy taken from +`develop` reads not current by design, since the snapshot is judged against the promoted +revision. Session entry runs no automatic check, by design: the trigger is suspicion, and the restated-rule symptom below is the loudest form of it. `docs/host-setup.md` "Fleet Skills Install" in the hub states the same cadence for the host side, and an automated refresh stays out of scope until the fleet has evidence the manual cadence fails. @@ -60,12 +67,11 @@ refresh stays out of scope until the fleet has evidence the manual cadence fails - **A carried section that differs from the hub in a way that reads as a genuine local addition** rather than plain staleness, the exact case `carried-instruction-file-guard` exists to protect. - Report precisely what differs and stop there. Per AUDIT.md, a downstream repo does not write its - own audit report or resync itself against the hub, it names what it found and points at - `resync-a-repo`, run from a hub checkout, as the next step. + Report precisely what differs and stop there, naming a resync as the next step, where + `carried-instruction-file-guard` decides the merge. - **Anything the installer alone cannot resolve**, a broken `claude` CLI marketplace registration, a settings or ruleset drift, a workflow interface mismatch. Name it and hand it to - the maintainer or a hub-driven resync rather than patching around it locally. + the maintainer or a resync per the hub's `RESYNC.md` rather than patching around it locally. ## Answering "why isn't a fleet rule applying" diff --git a/.github/skills/comment-and-doc-style/SKILL.md b/.github/skills/comment-and-doc-style/SKILL.md index 806ce4e..7c6f9d6 100644 --- a/.github/skills/comment-and-doc-style/SKILL.md +++ b/.github/skills/comment-and-doc-style/SKILL.md @@ -40,6 +40,24 @@ Use each tool's official casing in task labels, docs, and prose: `.NET` (not `.N disables (for example `MD013` line length) stays disabled, do not "fix" it. `MD033` inline HTML stays enabled: HTML comments, and `details`/`summary` (no Markdown equivalent for a collapsible), are allowed, everything else with a native Markdown equivalent uses the Markdown. +- **A repo-local exclusion goes in a nested config, never in the root one.** The shared + `.markdownlint-cli2.jsonc` at the repo root is fleet-fixed, and its `ignores` list covers only + what every repo has, third-party Markdown under `node_modules`. A repo excluding a subtree of + its own that it does not treat as authored prose, a committed data archive, a vendored theme, + or a hand-maintained record, puts a `.markdownlint-cli2.jsonc` carrying its own `ignores` + beside that content. A config inside a + tree that is re-imported or re-vendored wholesale is deleted by the next refresh, so it is + re-added with the import. Excluding through the CI workflow's negated glob input instead is a + CI-only fix, and leaves those same files flagged for anyone who runs the linter locally. +- **What decides whether a nested config works.** It applies to the directory it sits in and to + every subdirectory below it, and it filters those files even when a run names them explicitly + as arguments, so a bare local run and the CI step honor it alike. Its `ignores` patterns + resolve against that directory rather than against the repo root, so an entry written + repo-root-relative matches nothing and reports no error saying so. Its settings + merge with those above it rather than replacing them, so the fleet rule block still governs + the files it does not exclude. And the exclusion has to be expressed as `ignores`: the `globs` + and `gitignore` keys are read only from the config in the directory the linter is run from, so + a nested copy of either is inert. - **Spelling is US English**, checked by CSpell against the shared `cspell.json` (`"language": "en-US"`, so a British spelling is flagged). Add a project term to `cspell.json`'s `words` list, never to a `.code-workspace`'s own `cspell.words` block. @@ -158,6 +176,111 @@ Sub-topics take a `-` after the comment marker, each elaborating a distinct item # - template-compile-test builds one example device per template. ``` +A change that adds a comment line in code or config fails the `comment-added` rule in the prose +gate. It reports a prose comment that opens its own line, in the diff's scope, and a diff counts a +modified line as an added one, so rewording one and re-indenting one each report it. That is the +rule's cost and the label is its answer, since a comment worth keeping takes the same label as a +comment worth writing. A trailing comment is out of scope, since which mid-line marker opens a +comment differs by language in ways a gate cannot settle from the marker alone. A docstring is not +a comment line, an instruction to a tool is not a comment the rule reads, and Markdown is out of +scope. + +Deleting the comment is the ordinary answer, since the bullets above already say what one has to +earn. Where a comment is genuinely owed, and a rule requiring one is the clearest case of that, the +pull request carries the `comments` label and the gate stands down for that change. Locally a +`PROSE_ALLOW_COMMENTS` does the same, for as long as it is set to anything but a false spelling, +and `--allow-comments` does it for one run by hand. The label and the variable are separate deliberately, so a variable +left exported reaches the commit and never the merge gate. + +Three things about reaching those escapes read as a broken gate until they are known. The label is read off the event that started the run, so a label added after a run fails +applies to the next push rather than to a re-run of that one, and labeling the pull request when it +is opened is what avoids the round trip. The label reaches a repository only when the fleet label +set is applied to it, so a repository that has not had that applied since the label was declared +cannot carry it, and there the finding names a remedy that is not yet available. And the local +escape reaches a commit before any of that, which is where a repository meets this rule first, since +a hook runs on every commit while the label decides a pull request. + +## Issue, pull request, and commit references + +No comment, no docstring, and no instruction document names an issue, a pull request, or a commit. +The surfaces are code and workflow comments, a docstring, a documentation comment, the Skills trees, +and the fleet's own rule documents: `AGENTS.md`, `AUDIT.md`, `CLAUDE.md`, `CODESTYLE.md`, +`GOVERNANCE.md`, `OPERATIONS.md`, `RESYNC.md`, `STANDUP.md`, `WORKFLOW.md`, and +`.github/copilot-instructions.md`. A tracker, a history, a plan, and a README outside those trees +are the repository's own narrative and keep their references, as do a commit message and a pull +request body, which are the surfaces a reference belongs on. + +Two carve-outs, each stated as a single case. Whatever neither of them affirmatively permits is +banned by the paragraph above, which is the whole of the test and is why no list of banned cases +follows. The first: **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.** It does the same job +as the datasheet link, the vendor wiki link, and the SDK doc link the rule already leaves alone on +the adjacent line. The second is the revision record in the paragraph below. + +The second carve-out: a record whose subject is the revision itself keeps it. A disproved-claims +entry in `.github/copilot-instructions.md` names the revision its proof was read against, since a +proof is true of one tree at one revision and an entry whose subject has moved is deleted rather +than edited to look current. The revision there is the record's own load-bearing field rather than a +citation beside a claim, which is the distinction this rule turns on. + +Separately, `AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, and `WORKFLOW.md` carry no three-part +version and no commit SHA, full or abbreviated, whether a pin's value, an example, a minimum +version, or a fixed constant, since a pin's copy goes stale at the next Dependabot bump and every +other kind reads exactly like one. Neither carve-out above lifts this ban. Item 3 of this skill's +carried-doc-references reference says what to write instead of each and carries the audit that flags +a literal. + +Three reasons, and the first decides it. + +- **A reference is a second lookup, and the reader is already holding the file.** A comment earns + its place by explaining the line under it to whoever reads that line now. A number they have to + go and resolve somewhere else is the opposite of that. +- **The lookup can be impossible.** A repository may be private, so a reference in content carried + into a public one names something its reader cannot open at all. +- **It pollutes the content.** A rationale block that takes one more citation per round is how a + file comes to teach a house style the rules forbid, which is what happened here. + +The first carve-out is where all three fail at once, which is what makes it one case rather than a +taxonomy. Another repository's status is not a fact this file can hold, it changes without anyone +touching this file, and the constraint the citation stands in for is therefore unwritable. The +lookup is not impossible, that repository being public. And a URL line is not pollution on a surface +where the datasheet, the forum thread, and the component docs already sit on the adjacent lines, the +hostname being the only thing that separates them. A workaround whose justification is an open +report on the project it works around is the routine case, and a reader revisiting the workaround +needs to know whether the cause still stands. + +Move one of that carve-out's conditions and a reason comes back. An instruction document states its +constraint rather than citing a tracker for it, so the first reason holds there whatever the tracker +names. A private repository's tracker cannot be opened by a reader of the content carried into a +public repository, which is the second reason exactly. This repository's own tracker holds status +this file can state, so the first and the third hold on every surface. And a bare reference carries +no destination a reader can open at all, the hash-and-number form resolving against whichever +repository the reader happens to be in, which is why that carve-out is written as a URL. + +Write the constraint the reference was standing in for, or drop the clause where the reference was +the whole of its value. "A prior version re-scanned from every unmatched open, which was O(N^2)" +carries what the reader needs, and the number of the round that found it does not. Inside the +carve-out there is nothing to rewrite, the referenced thing being live status somewhere else, and +the carve-out is why that case needs no remedy rather than a remedy an author is expected to find. + +The `issue-ref` rule in the prose gate reads the pattern-detectable half of this: a bare reference +in a comment, in a Python docstring, and in instruction text, and in instruction text a URL naming +an issue or a pull request as well, written as an inline link destination, as a reference +definition, or bare in the prose. It reads one forge's URL paths, so a URL naming a tracker it does +not know is banned there and goes unreported. Whatever a gate does not reach is unlicensed all the +same, since the rule binds a reader rather than a scan. Reading the URL in instruction text is what +stops the gate reporting the bare spelling and passing the URL on the one surface it reads both, +since an author met by the bare form's finding is otherwise pointed at respelling the reference +rather than at removing it. On every other surface the gate reads the bare form alone, so a banned +URL is banned there and goes unreported. + +A bare commit reference is not a shape the prose gate can read, since a short SHA carries the same +shape as a blob id, a version fragment, and a fixture hash. The audit's version-literal scan reads it +in `AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, and `WORKFLOW.md`, bare or inside a URL, and +elsewhere the text above is the whole of what covers a commit. A reference in a +string literal is not read either: a test builds the numbers it asserts against, and reading those +would report a fixture rather than a claim about this repository. + ## Character set Agent-authored text is ASCII by default: documentation, code, comments, commit messages, and PR diff --git a/.github/skills/comment-and-doc-style/references/carried-doc-references.md b/.github/skills/comment-and-doc-style/references/carried-doc-references.md index 125bf0f..9b66d1b 100644 --- a/.github/skills/comment-and-doc-style/references/carried-doc-references.md +++ b/.github/skills/comment-and-doc-style/references/carried-doc-references.md @@ -14,7 +14,7 @@ verbatim, and this rule does not reach them. ## What is banned -Two things, in the files above: +Three things, in the files above: 1. **Any reference to the template repo**, in prose or in a link. The coordination flow that produced a carried file is machinery a consumer of that repo should never have to see, and @@ -25,6 +25,21 @@ Two things, in the files above: 2. **A sibling fleet repo named as an illustrative example** ("repo X does it this way", "see repo Y's adoption"), which couples the repos and rots as they diverge. To point at a current good example, name it in the onboarding or conformance issue, never in a carried doc. +3. **A three-part version or a commit SHA**, full or abbreviated, of any kind, in `AGENTS.md`, + `GOVERNANCE.md`, `CODESTYLE.md`, or `WORKFLOW.md`. The other files above are outside this item. A + pin's value ("SHA-pinned at hub release 2.0.x", with a real number in place of the x) is stale at + the next Dependabot bump, since the pin lives in the workflow or manifest that uses it, and it + sends the next agent to edit governance for a change that needed none. An illustrative example, a + minimum version, and a fixed constant read exactly like a copied pin, so no check can tell them + apart, and the rule covers them too. Write each as its mechanism instead: a pin as "SHA-pinned to + a hub release, with the release in a trailing comment", an example with a placeholder (`1.0.N` + publishing as `1.0.(N+1)`, or a dependency bump "from X to Y"), a minimum version by naming the + manifest or skill that holds it, and a fixed constant by what it is ("the all-zero placeholder + version"). A two-part language or runtime version, such as a minimum Python minor, stays. The + audit flags a three-part version, a full SHA, or an abbreviated one in those four files, outside + their verbatim sections downstream and across the whole file in the hub. + `.github/copilot-instructions.md` is outside it, since its disproved-claims records name a + revision by design, which the revision carve-out in `GOVERNANCE.md` "References" permits. ## The two exceptions diff --git a/.github/skills/copilot-instructions-keeper/SKILL.md b/.github/skills/copilot-instructions-keeper/SKILL.md index 9ee43bc..1899ce8 100644 --- a/.github/skills/copilot-instructions-keeper/SKILL.md +++ b/.github/skills/copilot-instructions-keeper/SKILL.md @@ -4,14 +4,17 @@ description: >- Helps keep a repo's .github/copilot-instructions.md in sync with the ptr727/ProjectTemplate hub canonical, and stops the one mistake specific to this file: silently wiping its repo-local "Disproved Claims" ledger entries during a resync. Use this whenever about to edit, overwrite, - re-vendor, or carry .github/copilot-instructions.md into a repo, whenever checking a repo for - drift against the hub or running a conformance sweep that touches this file, whenever GitHub - Copilot's review mechanics in this file look stale, wrong, or missing something the fleet - runbook should cover, or whenever standing up a new repo and carrying this file for the first - time. Also triggers on "why isn't the audit catching that this file is out of date," since the - fleet's mechanical audit checks this file, at intent fidelity, for file presence and each named - section's heading, never for content drift inside a section, so nothing else notices a stale - section here except a live check like this one. + re-vendor, or carry .github/copilot-instructions.md into a repo, whenever checking a repo's copy + for drift against the hub, whenever GitHub Copilot's review mechanics in this file look stale, + wrong, or missing something the fleet runbook should cover, or whenever standing up a new repo + and carrying this file for the first time. Also triggers on "why isn't the audit catching that + this file is out of date," since the fleet's mechanical audit checks this file, at intent + fidelity, for file presence and each named section's heading, never for content drift inside a + section, so nothing else notices a stale section here except a live check like this one. An + `audit-a-repo` run checks this file's presence, headings, and a date-based staleness hint + without judging its content, so content drift in it stays this skill's, and a resync fires it + beside `resync-a-repo` and `carried-instruction-file-guard`, which guards the other four carried + files. --- # Copilot Instructions Keeper diff --git a/.github/skills/dotnet-codestyle/SKILL.md b/.github/skills/dotnet-codestyle/SKILL.md index 2cc81ee..7eb2089 100644 --- a/.github/skills/dotnet-codestyle/SKILL.md +++ b/.github/skills/dotnet-codestyle/SKILL.md @@ -49,8 +49,14 @@ All builds must complete without warnings, enforced three ways: surfaced as a warning fails the build and must be fixed or deliberately suppressed at the narrowest scope that fits (see Analyzer suppressions below), never left to accumulate. - **CI lint backstop.** CI runs the clean-compile checks on every PR as the authoritative gate. - The .NET mechanism for a local hook is Husky.Net, wired from the canonical - `catalog/snippets/husky/` config in the hub, hub-local and not carried into every fleet repo. + Husky.Net is wired from the canonical `catalog/snippets/husky/` config in the hub, hub-local + and not carried into every fleet repo. Its hook needs a .NET tool manifest declaring + Husky.Net, which the snippet does not ship. A repo keeping no such manifest takes the other + canonical shape, `catalog/snippets/pre-commit/`, and so does a repo that simply prefers the + `pre-commit` framework. Each shape carries whichever language checks its own repo keeps. A repo may also wire an equivalent hook of its own at `.husky/pre-commit`, + enabled with `core.hooksPath` and sourcing nothing, since the husky snippet's own hook sources + a file only `dotnet husky install` generates. That path and `.pre-commit-config.yaml` are the + two the audit reads. GOVERNANCE.md's hub-only "Running the Linters Locally (Known-Working Invocations)" section carries the obligation itself, what the hook must cover, its audit treatment, and the per-clone enablement steps. @@ -81,7 +87,7 @@ in place. ### Build tasks Run these from VS Code's task runner (Terminal -> Run Task) or an agent's task-running tool. The -three clean-compile tasks are carried verbatim, and a repo adds its own convenience tasks (tool +three clean-compile tasks are carried unchanged, and a repo adds its own convenience tasks (tool updates, dependency upgrades, benchmarks) on top: - `.NET Build`: build with diagnostic verbosity *(clean-compile)* @@ -97,9 +103,12 @@ updates, dependency upgrades, benchmarks) on top: `dotnet format style --verify-no-changes --severity=info --verbosity=detailed`. - **`dotnet-outdated-tool`** checks for dependency updates, and Nerdbank.GitVersioning owns version management. -- CI is the authoritative lint backstop. The .NET mechanism for a local pre-commit hook is - Husky.Net, wired from `catalog/snippets/husky/` in the hub, hub-local and not carried into every - fleet repo, and covering the shared doc gates alongside the language checks. +- CI is the authoritative lint backstop. A repo already keeping a .NET tool manifest declaring + Husky.Net wires its local pre-commit hook from `catalog/snippets/husky/` in the hub, hub-local + and not carried into every fleet repo. A repo keeping none, or one preferring the `pre-commit` + framework, takes `catalog/snippets/pre-commit/` instead. Either shape covers the shared doc + gates alongside the language checks. Each snippet's own README names the per-clone steps and + the second file to copy alongside it. - **Required VS Code extensions**: CSharpier, markdownlint, CSpell. Use the workspace settings without overrides. @@ -201,7 +210,7 @@ The .NET mechanics, narrowest first: xUnit v3 (`xunit.v3`, not the legacy `xunit`) + AwesomeAssertions (`.Should()` API, never native asserts). Arrange-Act-Assert pattern, descriptive underscore names, `[Theory]`/`[InlineData]` for -parameterized tests. See `references/testing.md` for the framework setup template. +parameterized tests. A test project on `xunit.v3` 4.0.0 or later is MTP-based, and also carries a `global.json` runner declaration, a `Microsoft.Testing.Extensions.CodeCoverage` floor, and no `xunit.runner.visualstudio`. See `references/testing.md` for the framework setup template and that configuration. ## Project configuration diff --git a/.github/skills/dotnet-codestyle/references/testing.md b/.github/skills/dotnet-codestyle/references/testing.md index 5a84a17..4ec0c4e 100644 --- a/.github/skills/dotnet-codestyle/references/testing.md +++ b/.github/skills/dotnet-codestyle/references/testing.md @@ -23,3 +23,19 @@ 2. **Organization**: Arrange-Act-Assert pattern. 3. **Naming**: descriptive names with underscores. 4. **Theory tests**: use `[Theory]` with `[InlineData]`. + +## Microsoft.Testing.Platform and coverage + +A test project on `xunit.v3` 4.0.0 or later is MTP-based, and the .NET 10 SDK and later refuse to run one through the VSTest target, so such a project also carries: + +- a root **`global.json`** declaring `{"test": {"runner": "Microsoft.Testing.Platform"}}`, which is what selects the driver `dotnet test` runs the project through, +- **`Microsoft.Testing.Extensions.CodeCoverage`** at **18.9.0 or later**, in place of `coverlet.collector`, whose VSTest data collector MTP ignores without failing, +- no **`xunit.runner.visualstudio`**, the VSTest adapter MTP replaces. + +A project not yet MTP-based keeps the VSTest collector, and that lagging state is a migration owed rather than drift, until its own `xunit.v3` bump forces the move. + +**The version floor is load-bearing rather than cautionary.** Below 18.1.0 the extension is built against Microsoft.Testing.Platform 1.x, and an 18.0.x resolution, which is what a `>= 18.0.0` range picks, throws a `TypeLoadException` against the 2.x platform `xunit.v3` 4.0.0 carries, runs zero tests, and **still writes a well-formed Cobertura file reporting full coverage**, so only the non-zero exit says the run reported nothing. 18.9.0 is the first release on Microsoft.Testing.Platform 2.3.x, where every test project writes into the one shared `--results-directory` the invocation names rather than resolving that relative path per project. + +The CI invocation `WORKFLOW.md` D1.6 requires is `dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage`. Two further details of it are equally load-bearing, and neither failure reds the job on its own. `--coverage-output` stays unset, because pinning one filename gives every test project in the solution the same path and a solution with more than one then keeps only whichever ran last. Leaving it unset produces the default name `.cobertura.xml`, which `codecov-cli`'s own file finder does not match, so the report is renamed before the upload reads the directory, per `WORKFLOW.md` D1.6. + +**Diagnosing a local run.** `dotnet test` under the CI configuration reports zero tests on some machines where CI reports the full suite on the same SDK, which reads as a broken repository and is a broken driver. The target string the run prints separates the two: `net10.0` with no architecture means the driver resolved none, and `net10.0|` with no tests means the tests did not register, which is the case that points back at the three requirements above. diff --git a/.github/skills/drive-pr/SKILL.md b/.github/skills/drive-pr/SKILL.md index 2bfba18..7322ec1 100644 --- a/.github/skills/drive-pr/SKILL.md +++ b/.github/skills/drive-pr/SKILL.md @@ -2,18 +2,19 @@ name: drive-pr description: >- Drives a ptr727/ProjectTemplate fleet pull request through its review loop, feature branch into - develop and, when asked, on to a mergeable develop -> main promotion PR, applying the - pr-review-conduct disposition to every reviewer finding along the way: fix it, decline it with - evidence, defer it behind a filed issue, or put the call to the maintainer and wait for an - explicit answer in the same turn. Use this whenever asked to drive, land, take, chase, or push + develop and, when asked, on to a mergeable develop -> main promotion PR, disposing of every + reviewer finding along the way under pr-review-conduct's outcomes, carried here whole as a + generated include, and escalating to whoever dispatched the drive where the drive's own seat + cannot reach the maintainer. Use this whenever asked to drive, land, take, chase, or push a PR toward develop or main, or to run the review loop hands off instead of narrating each round. When the request does not say how far ("drive this PR", "land it"), ask once whether the target is develop or a mergeable main promotion PR, rather than guessing. Triggers even when only one PR is named, because a finding raised against the develop -> main promotion PR routinely needs its own feature -> develop fix cycle before the promotion PR can go green, and stopping at the first promotion-PR finding is the early exit this skill exists to prevent. Ends - at develop merged, or at a promotion PR meeting the pr-review-conduct Merge Gate, never merges - main itself, that is the separate merge-and-release skill, its own go-ahead. + at develop merged, or at a promotion PR meeting every pr-review-conduct Merge Gate item except + the maintainer's explicit permission to merge, never merges main itself, that is the separate + merge-and-release skill, its own go-ahead. --- # Drive PR @@ -32,19 +33,41 @@ promotion PR once the fix lands, is the early exit this skill exists to prevent. - Read the invocation for an explicit target first. "To develop" or "to dev" means stop once merged into develop. "To main", "through to main", or "all the way" means continue to a - mergeable promotion PR. Act on either without asking. + mergeable promotion PR. Act on either without asking. `session-handoff`'s attended session, + invoked by "resume the handoff", states the second, and naming that procedure names this skill. - When the request names no target ("drive this PR", "land it", "take this PR"), ask once, before the first push: develop only, or all the way to a mergeable main promotion PR. Recommend "all the way to main" as the default, a promotion PR left to go stale once develop is ready is the more common regret than driving one step too far. +- A drive dispatched as part of a larger run takes its target from the brief and asks no one, + since a subagent stopping to ask stalls a run designed to keep moving without one, and the seat + that dispatched it is the seat that holds the maintainer's answer. `backlog-burndown` is such a + run, and it briefs develop only, driving the develop -> main promotion pull request in its own + seat under "The Drive Loop"'s promotion steps. A brief naming no target at all is one to stop + and ask its dispatcher about, and asking the dispatcher is the whole of what a dispatched drive + does about an authorization question. A dispatched drive is not the seat that can verify a + grant, so it does not try: the responsibility for having the maintainer's go-ahead sits with the + dispatcher, and a worker inventing a check it cannot perform would only launder that + responsibility rather than discharge it. +- **A brief is never itself the authorization**, which binds the dispatching seat. What authorizes + a merge is what the maintainer said, recorded where the skill that carries the grant states its + scope, the way `backlog-burndown`'s own "What Invoking This Skill Authorizes" does. Writing an + approval into a brief creates none, since an agent cannot widen its own permission by writing + itself one, and a merge is the outward-facing act this skill's own "What Invoking This Skill + Authorizes" keeps tied to something the maintainer actually said. - A repo on the operational workflow model (registry `workflowModel: operational`) has no standing promotion PR expectation, confirm whether a promotion PR is even wanted before opening - one, per operational-vs-release-workflow's "Operational repositories" delta. + one, per branching-and-release-model's "Operational repositories (the complete delta)" + section. ## What Invoking This Skill Authorizes - Naming this skill, and answering its how-far question, is the maintainer's explicit, current go-ahead for every feature -> develop squash merge the drive performs to reach that target. +- A dispatched drive answers no such question, so what stands in its place is the go-ahead the + dispatching seat holds, per "How Far to Drive" above, and the drive performs the same merges on + it. Reading this bullet list is not how such a drive establishes that, since a worker cannot + verify a grant made in a seat it has no access to. - It is never authorization to merge the develop -> main promotion PR, or to dispatch a release. Those stay in merge-and-release, invoked on its own so the maintainer keeps a checkpoint before the harder-to-reverse step. @@ -56,10 +79,14 @@ promotion PR once the fix lands, is the early exit this skill exists to prevent. 1. Isolate into a worktree per repo-worktree, based on the branch that skill's base rule names, develop unless the task is explicitly about main-only content, before the first edit. 2. Commit the work, then run `local-strict-review` and record its pass in the order that skill - gives, its diff receipt following the commit, and its carried-content record instead preceding - the commit where the change moves a carried canonical unit in the repository that authors one, - because that ledger is tracked. Then push the branch and open the feature -> develop PR if it - does not exist yet. A push refused by a `.husky/pre-push` hook, which the hub carries and a + gives, its diff receipt following the commit. Where the change also carries the canonical ledger, + that skill's carried-content records instead precede the commit, because that ledger is tracked. + Then push the branch and open the feature -> develop PR if it does not exist yet. Open it + carrying the `comments` label where the change adds or edits a comment line in code or config, + since the prose gate refuses one otherwise and reads the label off the event that started the + run, so adding it after a failing check applies to the next push rather than to a re-run of + that one. + A push refused by a `.husky/pre-push` hook, which the hub carries and a repository has only if it adds one, is that gate working rather than an obstacle to route around, and that skill's refusal table says what each refusal means and what clears it. @@ -106,35 +133,76 @@ promotion PR once the fix lands, is the early exit this skill exists to prevent. 1 to 4 in its own worktree and branch, then return here. 7. The fix landing on develop updates the promotion PR's diff and head SHA on its own, re-request a review on the new head and continue the loop. -8. Repeat 6 and 7 until the promotion PR itself carries no open finding and its checks are green - on the current head. +8. Repeat 6 and 7 until the promotion PR meets every pr-review-conduct Merge Gate item except the + maintainer's explicit permission to merge. 9. Report the promotion PR number and its ready state. Do not merge it. ## Disposing of Every Finding -pr-review-conduct's five outcomes are the actual rule, this is the mapping to use while driving: - -- Real, so fix it, then step 2's own order again before replying with the fixing commit SHA - (outcome 1). This is the round the pass is most often skipped on, since the fix looks small and - the branch was already reviewed once, and a fix push carries content no pass has read exactly as - the first push did. -- Not real, or real but out of scope here, so decline in the thread with evidence: the command - and its output, the code path, or the rule that governs it. An assertion never closes a finding - on its own (outcome 2). -- Real and worth doing, but later, so file the issue first, then reply with its link (outcome 4). -- Real, fixable, but a value call rather than a scope boundary, or the agent genuinely does not - know which of the above applies, so ask the maintainer directly, whatever the runtime's own - interactive-question mechanism is, and get an explicit answer in the same turn, a plan to ask - later is resolution by silence (outcome 3). -- The same finding keeps recurring against correct code, fix the class, sharpen a name, add a - comment, or take the rule itself to the maintainer, rather than re-arguing the instance every - round (outcome 5). +The rule below is a generated include, so a defect in it is fixed in `pr-review-conduct` and +regenerated rather than edited here. A drive that cannot reach the maintainer directly, a +dispatched one being the ordinary case, escalates per `pr-review-conduct` "Escalate to the +maintainer when". + + + +1. **Real, so fix it, and fix the class rather than the instance.** A reviewer samples rather + than enumerates, so sweep for the finding's siblings before replying and fix each one sitting + in a file the diff already touches or that this change itself made wrong, filing the rest, per + `GOVERNANCE.md` "Verification Discipline". That sweep is owed the first time the finding is + raised, not once it recurs. Take the fix through `local-strict-review` the same way the push + that opened the pull request went, per `pr-review-conduct` "Expected review loop", then reply + with the fixing commit SHA. A branch already reviewed once has not been reviewed for the fix, which + is the round the `local-strict-review` pass gets dropped on and the churn `local-strict-review` + exists to stop. For a finding on platform-specific code (PowerShell, a macOS- or WSL-only + path), "fixed" means executed on that platform, per + `agent-conduct` "Before Claiming Done": a fix reasoned out by analogy to a tested equivalent + elsewhere is not yet fixed, and the reply says so rather than claiming the SHA closes it. +2. **Not real, or real but structurally out of scope, so decline in the thread with evidence.** + Disprove a wrong finding with the command and its output, the code path that makes it + impossible, or the rule that governs it. A finding that is factually correct but not this + repo's to fix (a verbatim-fidelity manifest entry byte-locking the section, ownership that + sits elsewhere) declines the same way: name the boundary and cite what proves it. Either shape + closes the thread on its own evidence, and the agent resolves such a thread itself rather than + leaving it for the maintainer. What makes that safe is the evidence being checkable by anyone, + a command and its output, the code path, the quoted rule, a byte-identical diff, so a decline + resting on anything weaker is not one of these. An assertion ("this is fine") does not close a + finding, and outcome 3's value call is the maintainer's, so that thread stays open until they + answer it. +3. **Real, fixable here, but deliberately left as is, a value call rather than a scope + boundary, so it is the maintainer's, not the agent's.** Reach for this only once outcome 2 is + ruled out, since a scope boundary declines on its own evidence and never needs this outcome at + all. State the finding and why the fix is unwanted, and get an explicit answer in the same + turn, before moving to other work. A plan to ask later is resolution by silence the moment + attention moves elsewhere. If the maintainer is not reachable right now, leave the thread open + and say so, rather than treating the intention to ask as the asking. +4. **Real and worth doing later, so file the issue first, then reply with its link.** A deferral + noted only in a thread is lost the moment the PR merges. File it in the repository where the + fix has to land, which for a finding against carried content is the repository that authors + that content rather than the one carrying it, since an issue filed where nobody may make the + fix is a deferral nobody can close. +5. **Keeps recurring although the class was swept, so the rule is what needs fixing.** A finding + raised repeatedly against correct code means the code is not communicating something: add the + comment, sharpen the name, narrow the interface, or fix the rule if the rule is wrong. + Bouncing the same point across rounds is the signal to escalate the rule itself, not to keep + re-arguing it. This is not where the class sweep lives, outcome 1 already owing that on the + first instance, and reaching here means the sweep ran and the finding came back anyway. + +**A disposition decided on one PR does not carry to the next.** The same finding shape recurring +on a sibling repo or PR, even within one batch or one session, gets its own outcome: its own +evidence-backed decline (outcome 2) or its own explicit maintainer answer (outcome 3). A prior +instance's outcome is context for the new one, never a standing answer to reuse in its place. + +`pr-review-conduct` "Every finding ends in one of five outcomes" keeps the full rule, and the +`drive-pr` Skill carries it whole as a generated include, applying it while driving. + + ## Mechanics Live Elsewhere - Review loop mechanics, the Merge Gate, and `scripts/pr_review.py`: pr-review-conduct. - Branch rules, never delete develop, the EOL-only conflict, issue-closing keywords belonging on - the promotion PR: operational-vs-release-workflow. + the promotion PR: branching-and-release-model. - Worktree isolation and post-merge cleanup: repo-worktree. ## Stop and Ask, Beyond the How-Far Question diff --git a/.github/skills/code-review/SKILL.md b/.github/skills/fleet-code-review/SKILL.md similarity index 89% rename from .github/skills/code-review/SKILL.md rename to .github/skills/fleet-code-review/SKILL.md index af77518..42d3cda 100644 --- a/.github/skills/code-review/SKILL.md +++ b/.github/skills/fleet-code-review/SKILL.md @@ -1,14 +1,16 @@ --- -name: code-review +name: fleet-code-review description: >- Reviews a pull request or change set against the repository's contracts, with explicit diff - coverage and no suppressed findings. Use this whenever asked to review code, a pull request, - a patch, or a proposed change, and whenever GitHub Copilot performs code review. Triggers even + coverage and no suppressed findings. Use this whenever asked to review code, a pull request, a + patch, or a proposed change, and whenever GitHub Copilot performs code review. Triggers even when the diff is documentation-only or workflow-only, because the review must load the - applicable general, language, documentation, and workflow skills before judging the change. + applicable general, language, documentation, and workflow skills before judging the change. This + skill judges a diff: disposing of a pull request's findings is `pr-review-conduct`, and the + pre-push pass over this branch is `local-strict-review`, which reuses this skill's criteria. --- -# Code Review +# Fleet Code Review ## Establish the Contract diff --git a/.github/skills/local-strict-review/SKILL.md b/.github/skills/local-strict-review/SKILL.md index cecb485..65373a9 100644 --- a/.github/skills/local-strict-review/SKILL.md +++ b/.github/skills/local-strict-review/SKILL.md @@ -9,13 +9,14 @@ description: >- PR-bound work. Triggers even when the change looks small or the same session already judged its own diff ready, because a self-review pass judging its own diff inherits its own blind spots, the exact gap this skill exists to close before a PR-hosted reviewer closes it instead. Reuses - `code-review`'s "Review the Change" criteria rather than restating them, and owns only this + `fleet-code-review`'s "Review the Change" criteria rather than restating them, and owns only this local, pre-PR moment. Once a pull request exists, `pr-review-conduct` and `drive-pr` own - triaging and disposing of what a PR-hosted reviewer finds. Also triggers whenever a change - edits rule text, a Skill, or any other canonical content this repository authors and other - repositories carry, because that content reaches a reviewer whole only when a repository - carries it for the first time, and a second pass reading each changed unit's whole text is - what moves that read into the repository that can act on what it finds. + triaging and disposing of what a PR-hosted reviewer finds. Also triggers whenever the periodic + canonical sweep is worked, which names the carried units whose text has moved past the pass + that read them together with a bounded slice of those nothing has read at all, because that + content reaches a reviewer whole only when a repository carries it for the first time, and a pass reading each named unit's whole text is what moves that read + into the repository that can act on what it finds. Editing such content owes no pass of its + own, so a change that moves a unit pushes and merges like any other. --- # Local Strict Review @@ -26,13 +27,13 @@ A coding agent that finishes a unit of work, judges it ready, and opens the pull ## What It Does -Dispatches one read-only subagent against this branch's full diff since it forked from its target branch. Resolve `` once, `develop` unless `repo-worktree`'s base-branch rule put this branch on `main` instead, then fetch it, `git fetch origin `, and diff against the merge-base, `git diff "$(git merge-base origin/ HEAD)"`. Stop and report a failed fetch rather than running the merge-base or diff commands anyway: an existing local `origin/` ref can still resolve after a failed fetch, and reviewing against it silently trades the current target for a stale one. Use the same resolved `` in every command below, never a literal `develop` alongside it. Naming the target branch explicitly matters: the branch's own `@{u}` tracking ref points at the branch's own remote once it has been pushed, not at the branch it targets, so anchoring there silently narrows a later run to only the diff since the last push instead of the full accumulated diff. That merge-base diff covers every commit already on the branch plus whatever is currently staged or unstaged, so it never reviews only the latest increment, at any of the moments this skill is invoked from. An empty diff is not the same as nothing to review, and it is never the signal to stop: it reports no untracked file at all, and it reports nothing for content a commit carries that the working tree has since put back. The untracked-file list below covers the first of those. The second is why the diff pass commits before reviewing, the carried-content pass below running against uncommitted content instead, since a removal or a restore that is committed leaves no net content to miss, and why the engine reads HEAD rather than this diff, its change set coming from the merge base against HEAD, the index and the working tree, so the two answer different questions. A fresh review of the full accumulated diff is what catches what per-push review misses, the exact evidence this skill exists to act on. +Dispatches one read-only subagent against this branch's full diff since it forked from its target branch. Resolve `` once, `develop` unless `repo-worktree`'s base-branch rule put this branch on `main` instead, then fetch it, `git fetch origin `, and diff against the merge-base, `git diff "$(git merge-base origin/ HEAD)"`. Stop and report a failed fetch rather than running the merge-base or diff commands anyway: an existing local `origin/` ref can still resolve after a failed fetch, and reviewing against it silently trades the current target for a stale one. Use the same resolved `` in every command below, never a literal `develop` alongside it. Naming the target branch explicitly matters: the branch's own `@{u}` tracking ref points at the branch's own remote once it has been pushed, not at the branch it targets, so anchoring there silently narrows a later run to only the diff since the last push instead of the full accumulated diff. That merge-base diff covers every commit already on the branch plus whatever is currently staged or unstaged, so it never reviews only the latest increment, at any of the moments this skill is invoked from. An empty diff is not the same as nothing to review, and it is never the signal to stop: it reports no untracked file at all, and it reports nothing for content a commit carries that the working tree has since put back. The untracked-file list below covers the first of those. The second is why the diff pass commits before reviewing, the sweep's own passes below running against uncommitted content instead on the change that carries their ledger, since a removal or a restore that is committed leaves no net content to miss, and why the engine reads HEAD rather than this diff, its change set coming from the merge base against HEAD, the index and the working tree, so the two answer different questions. A fresh review of the full accumulated diff is what catches what per-push review misses, the exact evidence this skill exists to act on. `git diff` never reports a path `git add` has not touched, so a newly created file sitting untracked would otherwise go unread. List it explicitly, `git ls-files --others --exclude-standard`, and read each result in full alongside the diff, the same as any other file the diff touches. The subagent reads the full content of every file the diff and the untracked-file list touch, not just the hunks, since cross-file and whole-file context is exactly what incremental review misses. It reports findings only. It never fixes, stages, or commits anything. -Review criteria are `code-review`'s "Review the Change" section, reused rather than restated here, plus three traps worth calling out explicitly for a pass that runs before a human or a PR-hosted reviewer ever sees the diff: unguarded type coercions, TOCTOU/race conditions, and platform-specific behavior differences. `code-review`'s separate "Publish Every Finding" section does not apply here: this skill has no PR to post a comment on and no coverage marker to close a review with, so its own report contract below replaces that section rather than extending it. +Review criteria are `fleet-code-review`'s "Review the Change" section, reused rather than restated here, plus three traps worth calling out explicitly for a pass that runs before a human or a PR-hosted reviewer ever sees the diff: unguarded type coercions, TOCTOU/race conditions, and platform-specific behavior differences. `fleet-code-review`'s separate "Publish Every Finding" section does not apply here: this skill has no PR to post a comment on and no coverage marker to close a review with, so its own report contract below replaces that section rather than extending it. ## Running It @@ -44,9 +45,9 @@ Task: adversarial review of this branch's diff against its merge-base with its t Paths: the files `git diff --name-only "$(git merge-base origin/ HEAD)"` and `git ls-files --others --exclude-standard` list, mandatory floor. Reading a specific unchanged caller or consumer beyond that list is in bounds only where a candidate finding's - proof actually depends on it, per code-review's own "follow data and control flow beyond the + proof actually depends on it, per fleet-code-review's own "follow data and control flow beyond the edited lines" instruction below, never as an open-ended exploration. -Rules that bind this task: quote `code-review`'s "Review the Change" section into the prompt, +Rules that bind this task: quote `fleet-code-review`'s "Review the Change" section into the prompt, plus flag unguarded type coercions, TOCTOU/race conditions, and platform-specific behavior differences explicitly. Do not quote "Publish Every Finding", this task's report contract is the Return line below, not a PR comment or a coverage marker. @@ -55,8 +56,12 @@ Bounds: read-only. No edit, no stage, no commit, no push, no PR-hosted write of ``` +Before dispatching, grep the tree for other statements of each rule the diff adds or changes, and add each file holding one to the `Paths:` floor, so a statement the diff has put in disagreement is read rather than missed. + **Model tier:** the strongest tier this session can reach, per `AGENTS.md` "Match the model tier to the judgment" and "Never tier down the seat holding the judgment", applied here to the reviewer rather than the author. Run the pass on the same tier that authored the change when only one tier is reachable, a second, adversarially-prompted look still catches what the authoring pass's own "looks ready" judgment did not. +"This session can reach" means the tier this session can name when it dispatches the reviewer, rather than the tier this session is itself running on. A session deliberately tiered down for execution work, a worker dispatched by an orchestrator being the ordinary case, names a stronger tier for the reviewer where its harness lets it, since tiering down the author is the reason the reviewer must not follow it down. What a given harness and account actually permit varies, so treat this as the tier to ask for rather than one to assume. Where a dispatch reaches several tiers but exposes no way to name one, take what it gives and run the pass, on the same reasoning as the single-reachable-tier sentence above. A seat that cannot dispatch a subagent at all cannot perform this pass. Instead of pushing, it reports that it could not run the pass, to whoever dispatched it, or to the maintainer where nobody did. Either way it is a push that does not happen rather than a pass quietly skipped. The headless `run --backend` route under "Recording the Pass" is not the substitute: it runs a vendor CLI against its own review, which never carries the brief above, so it satisfies the rule this section states only where that separate route is what a capture point asked for. + ## Recording the Pass `scripts/local_review.py` is what makes this rule checkable rather than something each session has to remember. For the pass above, the engine only records that it happened, keyed on the content the reviewer actually saw, and its `run --backend ` subcommand is the separate case where a headless backend performs the review and records its own count. That receipt is what a capture point reads, the hub's own `.husky/pre-push` hook being the only one today, and a repository having none unless it adds one, since no manifest entry carries it. @@ -65,9 +70,9 @@ Commit first, then read the digest, then dispatch the subagent, then hand that s ```sh engine="/scripts/local_review.py" # in the hub itself, scripts/local_review.py -python3 "$engine" status --target # JSON, take contentDigest +python3 "$engine" status --target '' # JSON, take contentDigest # run the pass above, then: -python3 "$engine" record --reviewer agent-skill --target --expect-digest [--findings N] +python3 "$engine" record --reviewer agent-skill --target '' --expect-digest '' [--findings N] ``` Every subcommand here, `run --backend ` included, runs with the repository under review as the working directory, whichever repository that is. The engine takes no `--repo` and reads whichever repository it is run in, so the path names where the script lives and the working directory names what it measures. @@ -76,26 +81,28 @@ Every subcommand here, `run --backend ` included, runs with the repository `--expect-digest` is required rather than optional, and binding it to the earlier read is the whole point. A format-on-save or a hook autofix between the review and the record would otherwise be stamped as reviewed by a pass that never saw it. A refusal there is the content having moved, so the answer is another pass over the current content rather than another read of the digest. -Record the pass whatever it found, including nothing. The key covers the net content the branch introduces against its target rather than the commit series, so an interactive rebase that leaves the tree alone keeps the receipt valid, and changing one byte invalidates it. +Record the pass whatever it found, including nothing. The key covers the net content the branch introduces against its target rather than the commit series, so a rebase that leaves the tree alone keeps the receipt valid while the fork point holds, and changing one byte invalidates it. The key holds that fork point too, so rebasing onto a target that has moved retires the receipt although no file changed. **Why the commit comes first**, rather than being an ordering that could equally run the other way. A push delivers the commit, and the hook's tree check refuses a push whose tracked content differs from HEAD, so the record has to describe what HEAD holds. A commit that leaves the tree alone usually does not move the receipt's key, so diligence done before it still describes the same content, and a commit putting a path back to its base state drops it from the change set and does move it. Two reasons make the order matter anyway: staging a modified tracked file moves the key even though its content did not change, and a commit made after the record can carry content the pass never read. Reviewing earlier than this is still worth doing as ordinary diligence, and it does not substitute for the recorded pass: the digest read and the record bracket a window in which the tree holds still, and a commit inside that window ends it. The engine is hub-hosted per `GOVERNANCE.md` "Hub-Hosted Tooling", so a downstream repository reaches a hub checkout's copy rather than carrying one, which is what the path above is for. -## The Carried-Content Pass +## The Carried-Content Sweep A second pass under the same rule, run in the repository that authors canonical content other repositories carry, which in this fleet is the hub. `GOVERNANCE.md` "Verification Discipline" states the rule and why the ordering it corrects is a defect, and is not restated here. What it requires of a run is below. -**The unit is what a reviewer reads whole**, and `spec/files.json` rather than the document decides which, down to which files carry units at all. `canonical_review.py list` names the whole set and is the authority on it, so the rules are not paraphrased here, where a paraphrase can only drift from them. In the ordinary case a unit is one level-two section of a carried Markdown canonical, and `check` names each one it wants exactly as `record` takes it. The pass reads that unit's whole current text rather than the diff that moved it, because reproducing the carrier's read is the entire point, and a diff with surrounding context is a different read the pass above has already done. +**No change owes this pass.** Editing a carried unit refuses no push and fails no pull request. The passes are worked instead from the sweep's own issue, which a scheduled workflow in the authoring repository files with the units it asks for this round, and working that issue is the moment this section is for. What it produces is an ordinary pull request, carrying the ledger and whatever the passes had you fix, driven the ordinary way. + +**The unit is what a reviewer reads whole**, and `spec/files.json` rather than the document decides which, down to which files carry units at all. `canonical_review.py list` names the whole set and is the authority on it, so the rules are not paraphrased here, where a paraphrase can only drift from them. In the ordinary case a unit is one level-two section of a carried Markdown canonical, and `sweep` names each one it wants exactly as `record` takes it. The pass reads that unit's whole current text rather than the diff that moved it, because reproducing the carrier's read is the entire point, and a diff with surrounding context is a different read the pass above has already done. -Run it at the same model tier and in the same delegation shape as the pass above. The brief, the engine, its flags, and the point in the sequence where the record is written each differ, and all four are below. +Run it at the same model tier and in the same delegation shape as the pass above. The brief, the engine, and its flags each differ, and all three are below. ```text Task: adversarial review of one canonical unit, read as a repository carrying it for the first time reads it, whole, knowing nothing about what this branch changed in it. Paths: , read in full out of the file that key names. Read the whole unit, never a diff of it. -Rules that bind this task: , and judge the text +Rules that bind this task: , and judge the text as a reader who has only this unit: a claim it makes about a tool, a path, a command, or another rule is a defect wherever that claim is false, stale, or unverifiable from the unit itself, and an instruction it gives is a defect wherever following it literally fails. @@ -105,52 +112,59 @@ Bounds: read-only. Report a rule that looks incomplete rather than guessing at w ``` ```sh -git fetch origin # stop and report a failed fetch rather than measuring past it -python3 scripts/canonical_review.py check --target # each uncovered unit, with its digest +python3 scripts/canonical_review.py sweep # the units it asks for this round, with their digests +# exit 1 where it named any, which is the sweep working rather than the command failing # run the pass above over each unit it named, then, per unit: -python3 scripts/canonical_review.py record --reviewer agent-skill --unit '=' [--findings N] +python3 scripts/canonical_review.py record --reviewer agent-skill --target develop --findings '' --unit '=' ``` -These run in the authoring repository itself, which is the only repository this pass ever runs in, so the engine path is the plain one and there is no downstream side needing the `/` form the pass above shows for its own reach. Point an engine in one checkout at another checkout's tree and the second is measured with the first's unit model, while `record` stamps the ledger with a commit read from the second. - -`` is the branch this work targets, resolved once as the pass above resolves it and passed to `check` explicitly. Left off it defaults to `develop`, so a branch based on `main` is measured from a fork point nobody read. The fetch matters for the same reason it does above: the engine resolves `origin/` if it already exists and never fetches it, so a stale remote-tracking ref moves the fork point without saying so. Lagging, which is the ordinary way to be stale, moves it back and gates units this change never touched, and the reverse case, where the branch restores text the target has since changed, drops one it did move. Neither is announced, so the fetch is what keeps the fork point meaning what the reviewer read against. `check` names each uncovered unit with the digest to hand back, so nothing has to be looked up separately, and `list` is there for reading the whole set rather than for this loop. +These run in the authoring repository itself, which is the only repository this pass ever runs in, so the engine path is the plain one and there is no downstream side needing the `/` form the pass above shows for its own reach. Both resolve the repository from the working directory rather than from where the script sits, so the directory a command runs in is what decides which tree it measures, while the unit model and the manifest reader come from the checkout the script itself lives in. Running one checkout's copy against another's tree therefore measures the second tree by the first's rules, so run them in the tree being measured. `record` additionally stamps each pass with the merge-base against `--target`, which is provenance rather than coverage. The line above names it rather than leaning on the default, since a sweep's own branch is based on `develop`, and a `main`-based branch passes `--target main` instead. The digest is bound to the read for the same reason `--expect-digest` is above: recording a unit by name alone would stamp whatever the file holds at record time, so an edit between the review and the record would be attested to by a reviewer who never saw it. Record each unit whatever the pass found, including nothing. Fixing a finding is itself such an edit, so `record` then refuses the digest you were holding: that refusal is the content having moved rather than a fault in the record, and the answer is a read of the unit's new text, which is what a carrier will actually receive, recorded at its new digest. -**This pass records before the commit, where the pass above records after it**, and the two orders are opposite because the two records live in different places. A receipt sits in the worktree's git directory and can never be committed, so it is written once the commit has fixed what a push will deliver. This ledger and its burn-down are tracked files the commit has to carry, so writing them after that commit leaves the tree differing from HEAD, which is a state the pre-push hook refuses before either gate runs. The shortest order meeting both, and the one the refusal table below assumes, is: run this pass and record each unit, commit that together with the change, then read the digest, run the diff pass, record its receipt, and push. Committing the change first and the ledger in a second commit satisfies the same constraint and costs a commit. +**The ledger this writes is tracked content, so the commit has to carry it**, where the receipt the pass above writes never can be. Record each unit, commit the ledger together with whatever the passes had you fix, then read the digest, run the diff pass over that commit, record its receipt, and push. That is why the two records sit on opposite sides of the one commit. -**A unit nothing has read here yet is not this branch's debt.** `check` refuses the units this change moved, meaning the ones whose text it edited and the ones it newly carried, since widening the manifest hands a carrier content for the first time exactly as writing it would. Everything else is a burn-down entry in the hub's `reports/canonical-review.md` rather than a block on unrelated work. Working one of those off is worthwhile, and it is its own change rather than a tax on an unrelated one. +**A unit nothing has read here yet reaches the list a slice at a time.** `sweep` names every unit whose text has moved past a pass, and beside them a bounded number of the never-read ones, ordered by how recently the file each sits in was last committed, so recently authored content comes ahead of text that has sat unread for months rather than waiting behind the whole backlog. The key is the file rather than the unit, so committing to a file lifts every unread unit in it, and a unit becomes carried without being lifted wherever the manifest is widened on its own, the manifest being a file of its own. Declaring a section in the same commit that writes it lifts it like any other. `canonical_review.py report` renders that backlog in full, and working more of it off than the sweep asked for is worthwhile and is its own change. ## Disposing of Findings -Every finding maps to one of `pr-review-conduct`'s five outcomes, at whichever moment this pass ran: fixed (1), evidence-disproven (2), escalated to the maintainer for an explicit call (3), filed as a deferred issue (4), or, if it keeps recurring, taken as a signal to fix the class (5). Outcome 2 is the agent's own on its own evidence, covering a finding that is not real and one that is structurally out of scope. A finding judged real and left unfixed is never the agent's alone, so outcome 3 needs the maintainer's explicit answer in the same turn, reached only once outcome 2 is ruled out, and outcomes 4 and 5 reach the maintainer too, for the deferral and for the rule itself. Running this pass is required before every push toward a pull request, per `agent-conduct`. Two claims sit next to each other here and they point opposite ways, so they are stated apart rather than in one sentence. **The pass is mandatory**, and where a capture point enforces it, a push carrying content no recorded pass covers is refused. That refusal is the gate working rather than a fault to route around. **The findings stay advisory**, and the count a pass raises gates nothing at all, since a pass records that a review ran and never that the content is clean. The disposition above is what closes each finding, the same posture local lint holds today. It posts nothing to GitHub, it only reports to the session driving the work. A finding raised here and not fixed is not thereby resolved: the same finding shape reaching a PR-hosted reviewer later still gets its own fresh disposition, per `pr-review-conduct`'s "a disposition decided on one PR does not carry to the next." +Each bullet is a rule down to its `Why:` line, which is rationale rather than rule, so a stale rationale is a cleanup rather than a defect. + +- **Every finding ends in one of the outcomes that `pr-review-conduct` "Every finding ends in one of five outcomes" enumerates, reached here with no thread to reply in.** + - `Why:` a local finding and a PR-hosted one deserve the same dispositions, and one home for the list is what stops two copies of it drifting apart. +- **The agent disposing of a pass's findings classes each one `style`, `introduced`, or `pre-existing`, in that order.** `style` is a preference between defensible forms. `introduced` is any other finding on text this change wrote, rewrote, or removed, on text this change should have written, on a precondition this change left false elsewhere, or load-bearing for a decision this change puts to the maintainer. `pre-existing` is every other finding. + - `Why:` the reviewer is asked to omit preferences and returns some anyway, and `style` is classed first so that a preference on text this change wrote is not owed a fix. +- **Another round is owed only while an `introduced` finding is open.** Unless evidence disproves it, an `introduced` finding is fixed within the budget below, or escalated where `pr-review-conduct` "Escalate to the maintainer when" says so, a `pre-existing` one is filed once and blocks nothing, and a `style` one is declined with evidence, per `pr-review-conduct` "Every finding ends in one of five outcomes", the evidence being `fleet-code-review` "Review the Change"'s own rule to omit preferences. + - `Why:` a finding count over prose never reaches zero, so a loop closing on "did it find anything" does not close, where one closing on the false claim, the unfollowable instruction, or the wrong behavior this change put there does. +- **Two rounds of edits answer a pass, one budget per push and one per sweep issue.** Where an `introduced` finding is still open after the second round, editing stops and what remains goes to the maintainer with its counts per class, per `pr-review-conduct` "Escalate to the maintainer when". + - `Why:` past the second round nearly every finding is against text the previous round's fix wrote, so the rounds are producing the defects they find rather than removing them. +- **The pass is mandatory, and the count it records gates nothing.** A pass is recorded whatever it raised, so the record attests that a review ran rather than that the content is clean. + - `Why:` a gate reading the count would make a pass raising nothing the cheapest way through it, the opposite of what recording one is for. ## When to Run It -- Before the first push toward a pull request (`drive-pr`'s Drive Loop step 2, `pr-review-conduct`'s Expected review loop step 1). -- Before pushing a fix for a reviewer finding, the same self-review blind spot applies to a fix as to the original diff (`drive-pr`'s "Disposing of Every Finding", `pr-review-conduct`'s outcome 1). +- Before the first push toward a pull request, the push that opens it in `drive-pr` "The Drive Loop" and in `pr-review-conduct` "Expected review loop". +- Before pushing a fix for a reviewer finding, the same self-review blind spot applies to a fix as to the original diff (the fix outcome of `pr-review-conduct` "Every finding ends in one of five outcomes", which `drive-pr` "Disposing of Every Finding" carries). - Whenever `agent-conduct`'s "about to claim work is done, verified, green, or fixed" trigger fires for work that will become, or already is, a pull request. -- Before pushing a change that edits canonical content other repositories carry, or that newly carries some by widening the manifest, over each unit `check` names, per "The Carried-Content Pass" above. +- When the canonical sweep's issue is worked, over each unit it names, per "The Carried-Content Sweep" above. Editing such content is not itself one of these moments, and nothing refuses a push over it. -In the hub, `.husky/pre-push` checks the receipt, and the canonical-unit coverage beside it, at the push itself, so the moments above are where each pass is run rather than the only places it is noticed. A blocked push usually means one of those passes was skipped. Both capture points, that hook and the pull request one named below, are the hub's own, and a repository carrying this Skill has neither until one is carried to it, which is what makes the moments above the layer that actually binds everywhere. The hook is a backstop under this skill and not a replacement for it: it fires only in a clone that enabled `core.hooksPath`, it says nothing about a repository that carries no such hook, and it is bypassable by design, `--no-verify` being the documented route for a genuine pickle rather than for a diff nobody read. That route is not open in every seat. A Claude Code session running the fleet's agent-safety hook has the flag denied unconditionally, so where the rows below say a bypass is the answer, the answer in that seat is to report the state and hand the push to the maintainer rather than to force it. The hub's own `.github/actions/validate` composite action runs the canonical-unit half again as a step on every pull request into `main` or `develop`, which is what its workflow triggers on. That one needs no hooks path, runs whether or not any clone enabled one, and `--no-verify` does not reach it, which is what makes it the capture point a push cannot bypass where it applies. +In the hub, `.husky/pre-push` checks the receipt at the push itself, so the moments above are where the pass is run rather than the only place it is noticed. A blocked push usually means it was skipped. That hook is the hub's own, and a repository carrying this Skill has none until one is carried to it, which is what makes the moments above the layer that actually binds everywhere. The hook is a backstop under this skill and not a replacement for it: it fires only in a clone that enabled `core.hooksPath`, it says nothing about a repository that carries no such hook, and it is bypassable by design, `--no-verify` being the documented route for a genuine pickle rather than for a diff nobody read. That route is not open in every seat. A Claude Code session running the fleet's agent-safety hook has the flag denied unconditionally, so where the rows below say a bypass is the answer, the answer in that seat is to report the state and hand the push to the maintainer rather than to force it. No capture point anywhere gates the carried-content sweep: the hub's `.github/actions/validate` composite action renders the coverage burn-down into every run's job summary and fails no pull request over what that rendering shows, which is the sweep being periodic rather than enforced at a push. That step does still fail where the engine could not read what it needs at all, which is a boundary rather than a verdict about coverage. -**Read the refusal itself, which names its own case.** Some of the rows below are cleared by running a pass and some are cleared by nothing of the kind, and each row says which, so no count of either is kept here to go stale against the table. Some the hook decides before either engine runs, so there is no engine message under them, and the rows say where each one's detail comes from. +**Read the refusal itself, which names its own case.** Some of the rows below are cleared by running a pass and some are cleared by nothing of the kind, and each row says which, so no count of either is kept here to go stale against the table. Some the hook decides before the engine runs, so there is no engine message under them, and the rows say where each one's detail comes from. | The refusal says | What it means | What clears it | | --- | --- | --- | | No local review covers this branch's current content | The ordinary missing pass: no recorded receipt covers what this push delivers, either because none was recorded or because the content moved after one was | One pass over the branch's whole diff, recorded per "Recording the Pass" above | -| Tracked content differs from HEAD | A push delivers HEAD while a receipt covers the index and working tree, so the receipt does not describe this push. The hook prints the same headline for an unresolved merge and for a `git update-index --refresh` that exited above 1, naming each on its own line | Commit what is being pushed, then the pass, then the record. Where the change also moved a canonical unit, follow "The Carried-Content Pass" order instead, since committing first strands that ledger after the commit and each fix then lands on another row. Resolve the merge first where the hook names one, and run `git status` first where it names the refresh, since the content may not differ at all | +| Tracked content differs from HEAD | A push delivers HEAD while a receipt covers the index and working tree, so the receipt does not describe this push. The hook prints the same headline for an unresolved merge and for a `git update-index --refresh` that exited above 1, naming each on its own line | Commit what is being pushed, then the pass, then the record. Where the change also carries the canonical ledger, record those passes before that commit, per "The Carried-Content Sweep" above, since a ledger written after it leaves the tree differing from HEAD again. Resolve the merge first where the hook names one, and run `git status` first where it names the refresh, since the content may not differ at all | | The commit is not this worktree's HEAD | Any pushed branch ref carrying an object id that is neither this worktree's HEAD nor the all-zero id of a delete, which a push from a checkout sitting elsewhere reaches and so does a multi-ref push such as `git push --all` | Push one branch, the one this worktree holds. Where another branch is the one wanted, check it out in its own worktree first, per `repo-worktree` | -| Any wording saying the gate did not or could not run | An execution boundary rather than a verdict, which blocks because a gate that waves a push through when it could not run has stopped gating. The cause is named in that same message or in the engine error printed above it, and it is a missing Python interpreter, an unresolvable target, an unreadable receipt, a git command that failed, a manifest or ledger the engine could not read, or any unexpected failure | Whatever the message names, most often installing an interpreter per `docs/host-setup.md` or fetching the target branch. Never another pass | -| This branch changes N carried canonical unit(s) that no recorded pass covers | The carried-content pass was skipped for a unit this change moved or newly carried, and the refusal names each one with the digest to hand back | One carried-content pass per named unit, then `canonical_review.py record` for each, in the order "The Carried-Content Pass" above gives. The ledger that writes is tracked content, so the commit has to carry it and the diff pass comes after | -| A canonical refusal naming units this branch never touched | The fork point is not where the reader thinks it is. Either `origin/` does not hold the commit this branch forked from, since neither engine ever fetches it, or the branch is based on something other than `develop` and the hook, which passes no `--target`, measured it against `develop` regardless. Unlike the row below it still prints a record command, and taking that one records passes over units nobody read | `git fetch origin `, then `canonical_review.py check --target ` by hand for the real set, then pass and record what that names and commit the ledger with the change, per the carried-unit row above. Where the branch targets something the hook does not measure, no pass clears it, so the gate cannot judge that branch at all and the bypass is its answer, as in the row below | +| Any wording saying the gate did not or could not run | An execution boundary rather than a verdict, which blocks because a gate that waves a push through when it could not run has stopped gating. The cause is named in that same message or in the engine error printed above it, and it is a missing Python interpreter, an unresolvable target, an unreadable receipt, a git command that failed, or any unexpected failure | Whatever the message names, most often installing an interpreter per `docs/host-setup.md` or fetching the target branch. Never another pass | | The recorded pass was run against X and this check measured Y, printed under the missing-pass headline | The hook reads `develop` and nothing else, so a branch based elsewhere is measured against `develop` whatever the pass targeted, and the engine deliberately prints no record command, since the one it would print records a pass over a diff nobody read | One more pass against the branch this work actually targets, where it does target the measured one. Where it does not, the gate cannot judge the branch at all and the bypass is its answer | This table is the fleet's one enumeration of these, and every other surface states the principle and routes here rather than listing the shapes. That is deliberate: every review round that added a shape also left a restatement of it somewhere else, and keeping one table is what stops the next round doing the same. ## Mechanics Live Elsewhere -- Review criteria: `code-review`. +- Review criteria: `fleet-code-review`. - Delegation shape and model-tier discipline: `AGENTS.md` "Context and Delegation Discipline". - Branch base rule (`develop` unless the task is explicitly `main`-only): `repo-worktree`. - Finding disposition once a pull request exists, the Merge Gate, `scripts/pr_review.py`: `pr-review-conduct`, `drive-pr`. diff --git a/.github/skills/merge-and-release/SKILL.md b/.github/skills/merge-and-release/SKILL.md index 3eb31c3..fcf432f 100644 --- a/.github/skills/merge-and-release/SKILL.md +++ b/.github/skills/merge-and-release/SKILL.md @@ -5,13 +5,16 @@ description: >- asked, dispatches the release, in this hub always refreshing this machine's installed Skills from the newly promoted content as part of that release step, never as a separate ask. Use this whenever asked to merge main, ship a release, cut a release, or finish a promotion once its PR - is already green and fully resolved (produced by drive-pr or by hand). When the request does - not say how far ("merge main", "ship it"), ask once whether to merge only or merge and release, - rather than guessing which the maintainer wants this time. Triggers even when the phrasing is - as short as "merge main and release", because that already states the scope and is itself the + is already green and fully resolved (produced by drive-pr or by hand). When the request does not + say how far ("merge main", "ship it"), ask once whether to merge only or merge and release, + rather than guessing which the maintainer wants this time. Triggers even when the phrasing is as + short as "merge main and release", because that already states the scope and is itself the explicit, current go-ahead this skill acts on without asking again, though it never substitutes for the pr-review-conduct Merge Gate, a promotion PR that is not actually green and fully - resolved gets reported and stopped on, not merged. + resolved gets reported and stopped on, not merged. Where a merge or dispatch is actually + performed this skill wins over `branching-and-release-model`, which supplies the policy it + follows, and an `unattended-handoff` run invoked with scope main or release is the one standing + go-ahead it accepts in place of asking. --- # Merge and Release @@ -35,7 +38,7 @@ skill covers all of it, scoped down by what the maintainer actually asks for. merged without its release is the more common regret there. Recommend "merge only" as the default on an operational repo (registry `workflowModel: operational`), where a release is a separate, deliberate dispatch rather than an automatic follow-on to a promotion, per - operational-vs-release-workflow's "Operational repositories" delta. + branching-and-release-model's "Operational repositories" delta. - Detect the hub automatically, `git remote get-url origin` or `gh repo view --json nameWithOwner` naming `ptr727/ProjectTemplate`. There the release scope silently includes the Skills refresh, a downstream repo never sees it, it has no `.agents/skills` of its own to @@ -46,6 +49,10 @@ skill covers all of it, scoped down by what the maintainer actually asks for. - Naming this skill, and answering its how-far question, is the maintainer's explicit, current go-ahead to merge the promotion PR and to perform the scope chosen, for the one repo and PR in front of the agent. It is never a standing mode carried to the next PR. +- The one standing grant is an `unattended-handoff` run the maintainer invoked with scope `main` + or `release`, which names in advance each promotion that run's workers make, in that session + only. A worker handing a promotion here under it asks no how-far question, since the scope + states it, and the Merge Gate is still re-verified per promotion. - It is never permission to merge a PR that fails the Merge Gate. Re-verify the gate at invocation time, a check from earlier in the session can be stale. @@ -71,14 +78,9 @@ skill covers all of it, scoped down by what the maintainer actually asks for. else error("expected exactly one registry entry for \($name), got \($m | length)") end'`. Two cases, `none` versus anything else. When it reads `none`, report that no - release is configured, dispatch and run-correlation (step 6) do not apply. Otherwise (`two-phase`, - `dispatch-only`, or `publish-on-merge` alike), dispatch explicitly, `gh workflow run - publish-release.yml --ref main --repo owner/repo`, or `--ref develop` only when the maintainer - explicitly asked for a prerelease dispatch instead. `publish-on-merge`'s automatic publish is - gated on the actor being the codegen App merging a Dependabot or codegen PR - (operational-vs-release-workflow's publishing rules), so an ordinary human promotion merge, - exactly what step 3 just did, never triggers it, this step's explicit dispatch is what actually - ships the release here, not a side effect of the merge. + release is configured, dispatch and run-correlation (step 6) do not apply. Otherwise, dispatch + explicitly, `gh workflow run publish-release.yml --ref main --repo owner/repo`, or `--ref + develop` only when the maintainer explicitly asked for a prerelease dispatch instead. 6. Correlate the specific run this dispatch produced rather than assuming the newest one is it. `gh run list --repo owner/repo --workflow publish-release.yml --branch main --event workflow_dispatch --json databaseId,createdAt,headSha` (or `--branch develop` for a prerelease @@ -98,7 +100,12 @@ skill covers all of it, scoped down by what the maintainer actually asks for. states a different bound for this specific release: `timeout 2700 gh run watch --repo owner/repo --exit-status` on a host with GNU `timeout`, or the equivalent bounded-wait mechanism enforcing the same bound on a host without it (macOS without coreutils, native - Windows). Report a timeout separately from a completed run's own conclusion, the tag or + Windows). Never pipe `gh run watch` into another command unless the shell running it sets + `pipefail`, since without it a pipeline reports its last stage's exit status and + `gh run watch ... | tail` reports whether `tail` succeeded rather than whether the run + did. Read the watch's own exit status, or read the conclusion back with + `gh run view --repo owner/repo --json status,conclusion`. + Report a timeout separately from a completed run's own conclusion, the tag or version it produced. A run that fails, times out, or never starts is reported, never silently retried. 7. In the hub, when the chosen scope includes a release, bring this checkout to the merged @@ -126,9 +133,14 @@ skill covers all of it, scoped down by what the maintainer actually asks for. exactly the case a bare "up to date" would hide. `skills_install.py` stamps and installs from whatever this checkout's HEAD already is, so running it against a stale, unrefreshed, or locally-diverged `main` skips the refresh silently. Only then run `python3 scripts/skills_install.py --report`, then - `python3 scripts/skills_install.py` to install, and confirm `--report` now reads current, - regardless of whether step 5 or 6 dispatched, skipped, or failed a release, this step is gated - only on the chosen scope, never on the release outcome. This refreshes only the machine running + `python3 scripts/skills_install.py` to install, and confirm `--report`'s snapshot now reads + current. The two channels hold different things. The Codex and opencode copy keeps the + revision it was taken from, the promoted `main` here. The Claude Code channel loads the + registered checkout in place, the one `--report` names under `live`, and serves whatever it + holds at read time. Where that is this checkout, once step 8 returns it to `develop`, Claude + Code sessions on this machine load `develop`. `--report` exits on the snapshot alone. This step runs whether step 5 + or 6 dispatched, skipped, or failed a release, since it is gated only on the chosen scope, + never on the release outcome. This refreshes only the machine running this session, per skill-lifecycle, every other machine still refreshes on its own next run or `docs/host-setup.md` "Fleet Skills Install" cadence. 8. Run cleanup regardless of how steps 5 through 7 ended, no release configured, a dispatch @@ -212,7 +224,7 @@ skill covers all of it, scoped down by what the maintainer actually asks for. - The Merge Gate itself: pr-review-conduct. - Never delete develop, no-op republish, the operational repos' dispatch-only model: - operational-vs-release-workflow. + branching-and-release-model. - What the dispatch actually builds and publishes: workflow-ci-contract. - Skills install and report semantics: skill-lifecycle. - Cleanup mechanics: repo-worktree. diff --git a/.github/skills/pr-review-conduct/SKILL.md b/.github/skills/pr-review-conduct/SKILL.md index 836fdb3..6f7e367 100644 --- a/.github/skills/pr-review-conduct/SKILL.md +++ b/.github/skills/pr-review-conduct/SKILL.md @@ -2,21 +2,20 @@ name: pr-review-conduct description: >- Governs opening, driving, and merging a pull request review loop in a ptr727/ProjectTemplate - fleet repo: requesting a review after a push, triaging findings (including suppressed - low-confidence ones), replying and resolving threads, and deciding whether a PR is actually - mergeable. Use this whenever about to open a PR, immediately after creating one, about to merge - a PR, enable auto-merge, ask the maintainer for merge permission, push a fix and move on without - re-checking review state, or judge a PR "green" or "clean" from CI or mergeStateStatus alone. - Triggers even when the request sounds routine, such as "open a PR," "merge this," or "it's all - green, go ahead," because PR creation starts the review loop and mergeStateStatus: CLEAN - can go clean once checks pass and every known thread is resolved, while still saying nothing - about whether the review that resolved those threads covered the current head SHA, read the - full diff, or left a suppressed low-confidence finding, which opens no thread at all, - unanswered. Also triggers when a review loop looks stuck - (no review landing, findings that keep reappearing) or when deciding a finding is real, false, - deferred, or a deliberate decline. Provider-specific mechanics are implemented by - scripts/pr_review.py and bootstrapped by .github/copilot-instructions.md. This skill is the - contract those surfaces implement, not a replacement for them. + fleet repo: requesting a review after a push, triaging findings, replying and resolving threads, + and deciding whether a PR is actually mergeable. Use this whenever about to open a PR, + immediately after creating one, about to merge a PR, enable auto-merge, ask the maintainer for + merge permission, push a fix and move on without re-checking review state, or judge a PR "green" + or "clean" from CI or mergeStateStatus alone. Triggers even when the request sounds routine, + such as "open a PR," "merge this," or "it's all green, go ahead," because mergeStateStatus: + CLEAN can go clean once checks pass and every known thread is resolved, while still saying + nothing about whether the review covered the current head SHA, read the full diff, or left a + suppressed low-confidence finding, which opens no thread at all, unanswered. Also triggers when + a review loop looks stuck (no review landing, findings that keep reappearing) or when deciding a + finding is real, false, deferred, or a deliberate decline, or when a reviewer looks missing or + skipped. This skill is the contract that `scripts/pr_review.py`, `drive-pr`, and + `merge-and-release` implement: running the loop hands-off is `drive-pr` and merging main is + `merge-and-release`, each winning for its own action while this skill still binds the gate. --- # PR Review Conduct @@ -41,20 +40,77 @@ visible comments, routinely still carries a finding nobody has answered. Treatin 2. A review is confirmed on the **current head SHA**, matched by commit SHA rather than assumed from a green merge-state. A push makes checks go green *before* the re-review lands, and the matched review is **read**, not just counted. A review can carry the head SHA and still decline - the PR outright, or say it read only part of the changed files. `pr_review.py`'s - `review_on_head` names Copilot's own coverage specifically, the currently required reviewer, - not "no review of any kind covers this head": a trialed advisory reviewer (CodeRabbit, - Qodo) carrying the exact head under `other_reviewed`, with an empty review body and no new - threads, is its own ordinary "reviewed, nothing to flag" shape, not a missing review (#1066). + the PR outright, or say it read only part of the changed files. Where the round covering the + head states no coverage at all, the newest round that does state some stands in for it, and + only where the pull request changes the same set of files at both commits, since a statement + about a diff this head no longer has says nothing about this one. A head round's own + statement always wins, and `pr_review.py` refuses the carry where it cannot read that set at + both commits. The coverage this item + requires is Copilot's, and CodeRabbit and Qodo are advisory, since the hub's + `docs/pr-reviewer-evaluation.md` "Status" names Copilot the incumbent and says no candidate is + a required reviewer: an advisory reviewer's absence blocks nothing, while its findings owe + item 3 exactly as Copilot's do. `pr_review.py`'s `review_on_head` names Copilot's own coverage + specifically, not "no review of any kind covers this head": an advisory reviewer carrying the + exact head under `other_reviewed`, with an empty review body and no new threads, is its own + ordinary "reviewed, nothing to flag" shape, not a missing review. + A refusal is not that coverage, so this item stays unsatisfied under one, and the loop clears + it where it can. A file-count refusal is cleared by splitting the pull request, which is the + only cause on record that the loop can clear. `pr_review.py wait` exit `46` is the one nothing + the loop does clears, an account-quota refusal carrying the current head, which is the case + "Which Reviewers a Repository Actually Has" below states. Exit `47` is that same account state + read from the reviewer's activity elsewhere when this head carries none of its own, and exit + `41` holding across several heads with no cause its body names reaches it the slower way. + Those three are `wait`'s alone: `status` exits 0 over a refusal, carrying it as `refusal=` in + the digest line instead, so reading that exit code as the absence of one would falsely satisfy + this item on the exact state it exists to catch. That is where the + unsatisfied item goes to the maintainer, with the coverage the other reviewers gave that head + read rather than counted and named to them, and their permission under item 5 is what allows + the merge. Item 2 is never waived, and a merge over an unsatisfied one is theirs to authorize. 3. **Every** finding on that head SHA is closed: threads resolved, issue-level comments (which have no resolve action) triaged and replied to, **and** the low-confidence findings collapsed in the review body investigated and answered. Those appear in no thread, so polling threads alone reports a clean pass while they stand. The same holds for CodeRabbit's own "outside diff range" comments (`cr_outside_diff` in `pr_review.py`'s digest) and for Qodo's comment-only findings (`qodo_open`): neither opens a `reviewThreads` entry either, so - give each one the same triage the low-confidence findings above already get (#1058). Qodo's own + give each one the same triage the low-confidence findings above already get. Copilot's own + section for findings against code the pull request did not change, `Previously missed` in the + review body and `previously_missed` in the digest, is a fourth such class and takes that same + triage. It raises no thread for the same reason the others do not, and a finding it holds is + raised outright rather than withheld, so "the branch did not touch that code" is a reason to + decline one with evidence rather than a reason to leave it unanswered. Qodo's own `Resolved`/`Dismissed` self-tracked badge is a fast pre-triage signal, not a substitute for reading the finding, spot-verify against `gh pr diff` rather than trusting it outright. + Copilot's second review-body format states its own finding total, which the digest reads as + `overview=T/M` beside the number of review threads that round opened. A total larger than that + thread count is usually findings that format withheld, the same blind spot under a different + name, and it is sometimes the digest undercounting instead, where the round's enumeration + carries findings earlier rounds raised. Reading the review body is what tells the two apart, and + nothing else does, so read it and dispose of what it holds as this section's outcomes require. + `T` reads `?` where no total was found, which is a round stating none and equally one the digest + could not locate, so a `?` leaves this item unsatisfied and sends you to the body exactly as a + shortfall does. + The body read in that format so far carried no `Suppressed comments` heading, so `suppressed=` + finds nothing in it and there is no collapsed block to quote a count from: the digest's + shortfall and the body's own prose are what an answer cites instead. A later body that does + collapse one may have those findings counted by `suppressed=` and again in the shortfall, or may + have them counted once each, depending on whether its stated total includes them, which no body + read so far says. Answer what the body holds rather than what the two numbers add up to. + What closing a finding owes turns on whether it is `pre-existing`. A finding on text inside a + canonical Markdown unit, one the hub's `scripts/canonical_review.py list` names, classed + `pre-existing` by the classes `local-strict-review` "Disposing of Findings" defines for a + local pass, applied here to a PR-hosted finding, is outcome 4 of "Every finding ends in one + of five outcomes" below applied once per unit rather than once per finding: the round gathers + that unit's such findings onto the unit's tracker, an open hub issue whose title carries the + unit key, retitled by the change that moves the key and filed by whichever round first needs + it, and answers each finding with that issue's link, resolving a thread on that reply, so a + `pre-existing` remark on a sentence the change never touched costs one link rather than a + decline or an issue per finding. The batch runs in the hub, which authors the text of every + verbatim unit. A carrying repository routes a finding on a verbatim unit by fidelity rather + than by class, since a resync writes the whole text there: it declines the finding under + that section's outcome 2, ownership sitting elsewhere, and files it on the same tracker, + while a finding on an intent unit is filed there too, the carrier adapting its own copy + meanwhile, since the defect is still fixed at the source. Every other finding, a `style` + remark on untouched text included, takes its own outcome in that section. 4. Nothing in the review was a shape the tooling could not read (an unrecognized heading, a moved section, an unfamiliar coverage wording). An unrecognized shape blocks the gate on its own. File an issue naming it and quoting the body, rather than guessing what the new wording @@ -64,6 +120,39 @@ visible comments, routinely still carries a finding nobody has answered. Treatin The agent never merges on its own. A green or CLEAN PR with one open finding is not mergeable, full stop, whatever the merge-state field says. +## Which Reviewers a Repository Actually Has + +Whether a reviewer covers a repository at all is decided by product terms this fleet observes +rather than sets, and whether it reviewed this pull request is decided by those terms together +with configuration a repository commits itself. So respond to what the reviewers actually did on +the pull request in front of you, rather than deciding from a repository property what a reviewer +must have done. + +- **A reviewer that posted a skip notice is available for the asking.** It says it did not review + automatically, which is not the same as not reviewing at all. Comment `@coderabbitai review`, or + Qodo's `/review`, and wait for the result as with any other requested review. The agent driving + the loop posts that comment itself, on the same standing as requesting a review after a push. +- **A notice naming when the reviewer can next run is a rate limit, and asking does not clear + it.** It reads like the skip notice above and is the opposite case: the trigger returns the same + notice rather than a review, so a loop that keeps asking waits on something no amount of asking + produces. Wait for the time it names, or proceed on the reviewers that did run, since an advisory + reviewer blocks nothing. +- **Silence is not evidence, and is never read as one on its own.** A reviewer that has posted + nothing may not have started yet, may not cover this repository at all, or may have reviewed and + had nothing to say, which Merge Gate item 2 describes as its own ordinary shape and which posts + no comment to read. Read the reviews themselves rather than the comments alone, since the third + case appears only there. +- **Copilot's absence blocks, and is answered elsewhere.** Merge Gate item 2 requires Copilot's own + coverage of the current head, and the loop's own re-request step below is where a missing one is + answered, on the terms stated there. A refusal naming the account quota is its own case rather + than a review: it covers no head, so the gate stays unsatisfied, and nothing the loop does + clears it, since the refusal names no time to wait for and re-requesting returns it again. That + one goes to the maintainer, rather than into a wait with no stated end. + +Where a reviewer's behavior still surprises you after reading what it posted, the hub's +`docs/pr-reviewer-reference.md` records what each one does, what shapes it, and which repositories +its plan covers. + ## Expected review loop Open every fleet-owned pull request ready for review. Draft state delays the loop and causes @@ -78,15 +167,15 @@ that says only "open a PR" is not such an instruction. Run every `scripts/pr_review.py` command below from a hub checkout. The script is hosted there and is never carried into a downstream repository. -Run `local-strict-review` against the branch's current diff before step 1's push, and again before any fix push under outcome 1 below. Follow that skill's own ordering and record each pass, which is what a capture point reads, the hub's own `pre-push` hook being one and a repository having none until such a hook is carried to it. A push that hook refuses, where one is present, is the gate working rather than an obstacle to route around, and that skill's refusal table says what each refusal means and what clears it. +Run `local-strict-review` against the branch's current diff before every push this loop makes, the one that opens the pull request in step 1 and each one after it, whatever finding it answers and whether or not the branch was reviewed once already. A push that delivers content no pass has read is the case the rule is about, so a re-push of a tree a recorded pass already covers needs no second pass, the receipt being keyed on the branch's net content rather than on its commit series. A rebase that leaves the tree alone keeps it only while the merge base holds: rebasing onto a target that has moved retires the receipt although no file changed, and that push owes a pass like any other. Follow that skill's own ordering and record each pass, which is what a capture point reads, the hub's own `pre-push` hook being one and a repository having none until such a hook is carried to it. A push that hook refuses, where one is present, is the gate working rather than an obstacle to route around, and that skill's refusal table says what each refusal means and what clears it. 1. Push changes to the PR branch and open the pull request when it does not exist. 2. Run `scripts/pr_review.py status --repo /` once in the foreground and read its output. 3. Re-request a review for the **current head SHA**. Auto-trigger is unreliable, so request it - explicitly (mechanics in the Copilot runbook, `.github/copilot-instructions.md`), which step 4's - `wait` also does on its own, though it skips the request where a review already covers the head, - where the answer came outside a formal review, and where it detects drift. The UI is a fallback - only. + explicitly, which step 4's `wait` is what does, though it skips the request where a review + already covers the head, where the answer came outside a formal review, where it detects + drift, and where something is already in the request set, which is the condition the recovery + below clears. Requesting in the pull request UI is the maintainer's route rather than this loop's. 4. Run a bounded `scripts/pr_review.py wait --repo /` in a background process and read its terminal output. A completed review raising **no findings** is a valid terminal outcome, so do not re-trigger it or read silence as a missing review. A review whose body says it declined to review is the one @@ -94,25 +183,33 @@ Run `local-strict-review` against the branch's current diff before step 1's push head only repeats the decline. 5. Triage findings (see below). 6. Apply fixes or write a rationale for declines. -7. Reply to each thread and resolve what was addressed. +7. Reply to each thread, and resolve what was addressed and what was declined on evidence the + reviewer could check for itself, per outcome 2 below. 8. Re-run the loop after every fix push until the checks are green and no finding remains open. -The review effort setting is user-controlled. The workflow never selects or changes it. `status` reports `effort=lite`, `effort=balanced`, or `effort=max` when the completed review exposes that metadata, lowercased, and names an inherited setting apart from a chosen one in a separate `effort_source=default|explicit` field, both reading `unknown` when no effort line parses. Missing effort metadata reports `unknown` and does not change coverage or completion. A pending effort-labeled request can complete without a `copilot_work_started` timeline event, so absence of that event never proves the request is abandoned. The bounded timeout reports `PENDING` when no review or terminal answer arrives. After a timeout with `requested=yes`, rerun `wait` for another bounded interval by default because the request may still be active. If the maintainer directs a retry, remove Copilot in the pull request UI, add it again, and rerun `wait`. This recovery replaces only the review request and never changes the effort setting. +The review effort setting is user-controlled. The workflow never selects or changes it. `status` reports `effort=lite`, `effort=balanced`, or `effort=max` when the completed review exposes that metadata, lowercased, and names an inherited setting apart from a chosen one in a separate `effort_source=default|explicit` field, both reading `unknown` when no effort line parses. Missing effort metadata reports `unknown` and does not change coverage or completion. A pending effort-labeled request can complete without a `copilot_work_started` timeline event, so absence of that event never proves the request is abandoned. The bounded timeout reports `PENDING` when no review or terminal answer arrives. `requested=yes` reports that the request was accepted rather than that a round is coming. An accepted request can sit unpicked, printing the same digest as one about to be served, so a driver reading that field as progress is waiting on evidence it does not hold. After a timeout carrying it, rerun `wait` for another bounded interval by default, because the request may still be active. Where a second bounded wait times out as well, read the pending set, and clear it only where no human or team reviewer is requested alongside the bot, because the clear replaces that set rather than adding to it and nothing restores a request it drops. A stall on a pull request that has a human or team reviewer requested goes to the maintainer instead, and so does one still pending after the wait that follows a clear. The clear leaves the next `wait` nothing outstanding to defer to, so that run requests afresh, and its own auto-request line is what says so, since `wait` reads the reviewer's node id out of the repository's recent reviews and polls without requesting where it finds none. The hub's `docs/pr-reviewer-reference.md` carries the mutation, and an agent seat can run it, where removing and re-adding the reviewer in the pull request UI is a step only the maintainer can take. This recovery replaces only the review request and never changes the effort setting. Drive to green, a review confirmed on the latest head SHA and every actionable finding closed, -then apply the Merge Gate above. **Never exit the loop early.** A round count is not a stopping -condition, and neither is patience running out. Reporting only that the PR was opened is an early -exit unless the maintainer explicitly instructed the agent not to monitor or drive its review. +then apply the Merge Gate above. **Never exit this PR-hosted loop early.** Its pre-push +counterpart is bounded instead by `local-strict-review` "Disposing of Findings". A round count +is not a stopping condition here, and neither is patience running out. Reporting only that the +PR was opened is an early exit unless the maintainer explicitly instructed the agent not to +monitor or drive its review. After an authorized merge, run the `repo-worktree` post-merge cleanup procedure unless the user explicitly asks to retain the checkout or branch. The pull request loop is incomplete while its finished worktree or local task branch remains. It is also incomplete until the base clone returns to fetched and fast-forwarded `develop`. ## Every finding ends in one of five outcomes -1. **Real, so fix it.** Take the fix through `local-strict-review` the same way step 1's push - went, then reply with the fixing commit SHA. A branch already reviewed once - has not been reviewed for the fix, which is the round this gets dropped on and the churn - `local-strict-review` exists to stop. For a finding on platform-specific code - (PowerShell, a macOS- or WSL-only path), "fixed" means executed on that platform, per +1. **Real, so fix it, and fix the class rather than the instance.** A reviewer samples rather + than enumerates, so sweep for the finding's siblings before replying and fix each one sitting + in a file the diff already touches or that this change itself made wrong, filing the rest, per + `GOVERNANCE.md` "Verification Discipline". That sweep is owed the first time the finding is + raised, not once it recurs. Take the fix through `local-strict-review` the same way the push + that opened the pull request went, per `pr-review-conduct` "Expected review loop", then reply + with the fixing commit SHA. A branch already reviewed once has not been reviewed for the fix, which + is the round the `local-strict-review` pass gets dropped on and the churn `local-strict-review` + exists to stop. For a finding on platform-specific code (PowerShell, a macOS- or WSL-only + path), "fixed" means executed on that platform, per `agent-conduct` "Before Claiming Done": a fix reasoned out by analogy to a tested equivalent elsewhere is not yet fixed, and the reply says so rather than claiming the SHA closes it. 2. **Not real, or real but structurally out of scope, so decline in the thread with evidence.** @@ -120,8 +217,12 @@ After an authorized merge, run the `repo-worktree` post-merge cleanup procedure impossible, or the rule that governs it. A finding that is factually correct but not this repo's to fix (a verbatim-fidelity manifest entry byte-locking the section, ownership that sits elsewhere) declines the same way: name the boundary and cite what proves it. Either shape - closes the thread on its own evidence. An assertion ("this is fine") does not close a finding, - a decline needs evidence the reviewer itself could check. + closes the thread on its own evidence, and the agent resolves such a thread itself rather than + leaving it for the maintainer. What makes that safe is the evidence being checkable by anyone, + a command and its output, the code path, the quoted rule, a byte-identical diff, so a decline + resting on anything weaker is not one of these. An assertion ("this is fine") does not close a + finding, and outcome 3's value call is the maintainer's, so that thread stays open until they + answer it. 3. **Real, fixable here, but deliberately left as is, a value call rather than a scope boundary, so it is the maintainer's, not the agent's.** Reach for this only once outcome 2 is ruled out, since a scope boundary declines on its own evidence and never needs this outcome at @@ -130,17 +231,25 @@ After an authorized merge, run the `repo-worktree` post-merge cleanup procedure attention moves elsewhere. If the maintainer is not reachable right now, leave the thread open and say so, rather than treating the intention to ask as the asking. 4. **Real and worth doing later, so file the issue first, then reply with its link.** A deferral - noted only in a thread is lost the moment the PR merges. -5. **Keeps recurring, so fix the class, not the instance.** A finding raised repeatedly against - correct code means the code is not communicating something: add the comment, sharpen the name, - narrow the interface, or fix the rule if the rule is wrong. Bouncing the same point across - rounds is the signal to escalate the rule itself, not to keep re-arguing it. + noted only in a thread is lost the moment the PR merges. File it in the repository where the + fix has to land, which for a finding against carried content is the repository that authors + that content rather than the one carrying it, since an issue filed where nobody may make the + fix is a deferral nobody can close. +5. **Keeps recurring although the class was swept, so the rule is what needs fixing.** A finding + raised repeatedly against correct code means the code is not communicating something: add the + comment, sharpen the name, narrow the interface, or fix the rule if the rule is wrong. + Bouncing the same point across rounds is the signal to escalate the rule itself, not to keep + re-arguing it. This is not where the class sweep lives, outcome 1 already owing that on the + first instance, and reaching here means the sweep ran and the finding came back anyway. **A disposition decided on one PR does not carry to the next.** The same finding shape recurring on a sibling repo or PR, even within one batch or one session, gets its own outcome: its own evidence-backed decline (outcome 2) or its own explicit maintainer answer (outcome 3). A prior instance's outcome is context for the new one, never a standing answer to reuse in its place. +`pr-review-conduct` "Every finding ends in one of five outcomes" keeps the full rule, and the +`drive-pr` Skill carries it whole as a generated include, applying it while driving. + ## Triaging findings **A low-confidence (suppressed) finding is not a low-value one.** Judge each against the code, @@ -172,6 +281,16 @@ from a hub checkout. Do not use a provider connector or reconstruct the GitHub m - A finding is judged real but should not be fixed. That decision is never the agent's alone. - An architectural redesign is proposed rather than a bug fix. +An agent that cannot reach the maintainer directly, a dispatched subagent being the ordinary case, +escalates to whoever dispatched it and stops that unit of work there. It never substitutes its own +judgment for the escalation because asking is inconvenient from where it sits, and it never resolves +the thread to keep moving. A dispatcher receiving one puts it to the maintainer at the point that +work stopped, per `GOVERNANCE.md` "Communicating with the User", and deciding it instead so the +dispatcher's own work keeps moving is the same resolution by silence this skill's own +ask-the-maintainer outcome forbids, one seat further from the maintainer. The escalation may travel through several seats, and what stays stopped is the +escalated unit of work, in whichever seat holds it, until the answer arrives. A dispatcher's other +work is not stopped by it. + ## Mechanics Live Elsewhere This skill is the provider-agnostic contract. Use `scripts/pr_review.py` from a hub checkout for @@ -182,5 +301,5 @@ shapes in one call. `wait` requests and polls in-process. `comment` posts a PR-c answer after it reads the PR node ID. `reply` answers a thread by matching the finding's own words instead of a line number a fix push can move, and resolves it only when `--resolve` is given. The repository's -`.github/copilot-instructions.md` bootstraps Copilot into the `code-review` skill and its stable +`.github/copilot-instructions.md` bootstraps Copilot into the `fleet-code-review` skill and its stable coverage marker. Do not reconstruct the API operations by hand. diff --git a/.github/skills/python-codestyle/SKILL.md b/.github/skills/python-codestyle/SKILL.md index 210a67b..160bf0b 100644 --- a/.github/skills/python-codestyle/SKILL.md +++ b/.github/skills/python-codestyle/SKILL.md @@ -96,9 +96,15 @@ those. These are documented commands, and the hub's `vscode-tasks-python.json` s VS Code tasks mirror that the fleet baseline expects. Every command-executing task in it is `type: process`, and every aggregator is `dependsOn`-only. Neither chains with `&&`, so the mirror runs the same on any task shell. CI runs the same clean-compile commands as the authoritative -backstop. The Python mechanism for a local hook is the `pre-commit` framework, wired from the -canonical `catalog/snippets/pre-commit/` directory, hub-local and not carried into every fleet -repo. That directory's own README names the second file to copy alongside the config. +backstop. A repo that keeps no .NET tool manifest declaring Husky.Net, or one that prefers the +`pre-commit` framework, wires its local hook from the canonical `catalog/snippets/pre-commit/` directory, +hub-local and not carried into every fleet repo. Any repo may instead wire an equivalent hook of +its own at `.husky/pre-commit`, enabled with `core.hooksPath` and sourcing nothing. That path and +`.pre-commit-config.yaml` are the two the audit reads. The runner is bounded by the toolchain the +repo already keeps rather than by the languages the hook checks, so a repo keeping a Husky.Net +manifest may run these same Python checks from the `catalog/snippets/husky/` shape instead. Each +shape carries whichever language checks its own repo keeps. The `pre-commit` directory's own +README names the second file to copy alongside the config. GOVERNANCE.md's hub-only "Running the Linters Locally (Known-Working Invocations)" section carries the obligation itself, what the hook must cover, its audit treatment, and the per-clone enablement steps. diff --git a/.github/skills/python-codestyle/references/profiles.md b/.github/skills/python-codestyle/references/profiles.md index b7eedfb..556c3d2 100644 --- a/.github/skills/python-codestyle/references/profiles.md +++ b/.github/skills/python-codestyle/references/profiles.md @@ -15,8 +15,6 @@ in review). The axes that commonly vary per repo: - **Versioning / publishing**: a published package (`_version.py` plus a version source, `uv build`, and a PyPI publish step), or a source-only repo with a static `version` and no publish step (see Versioning below). -- **Disabled markdownlint rules**: repo-specific, `.markdownlint-cli2.jsonc` at the repo root is - the source of truth, not any example rule named here. - **VS Code config home**: editor settings/extensions may live in `.vscode/*.json` or the `.code-workspace`, while tasks/launch/debug configs can only be external `.vscode/*.json` (they cannot live in the workspace file). The repo's own `tasks.json` sits wherever it keeps it, diff --git a/.github/skills/python-codestyle/references/testing.md b/.github/skills/python-codestyle/references/testing.md index 0dae3fd..b4368a7 100644 --- a/.github/skills/python-codestyle/references/testing.md +++ b/.github/skills/python-codestyle/references/testing.md @@ -7,6 +7,8 @@ are in `references/profiles.md`. Use `pytest` with configuration in `[tool.pytest.ini_options]`. Default invocation: `uv run pytest`. +**Coverage.** A build-profile repository with tests declares **`pytest-cov`** among its test dependencies, a dev dependency group where the repository is a uv project and a `requirements*.txt` entry where it is on pip, and selects the coverage source in its own `pyproject.toml`, an `addopts` entry of `--cov=` in practice. CI adds `--cov-report=xml` to the invocation, so the repository owes the dependency and the selector rather than that flag. Both halves are load-bearing and they fail differently: without the dependency the CI run exits non-zero on an unrecognized argument, and with the dependency but no selector it measures nothing, writes no file, and exits zero. Leave the report at the repository root as `coverage.xml`, the one path CI names. `WORKFLOW.md` D1.6 owns the pipeline half, the upload and the check that fails when no report was written. + - One test file per module under test, named `test_.py`. - Test functions named `test__`, descriptive and not numbered. - Use fixtures (defined in `conftest.py` for shared ones, or per-test for narrowly-scoped) instead diff --git a/.github/skills/repo-worktree/SKILL.md b/.github/skills/repo-worktree/SKILL.md index 4dda047..3a5888e 100644 --- a/.github/skills/repo-worktree/SKILL.md +++ b/.github/skills/repo-worktree/SKILL.md @@ -62,17 +62,19 @@ from `develop` unless the task is explicitly about `main`-only content, per `GOV is whatever it last fetched rather than the branch it names. The base clone is a fetch source, not a place to do task work. `fetch` and `worktree add` run -against it for that purpose, and outside "Listing and Cleanup"'s own terminal step below, nothing -else does: never `checkout`, `pull`, `reset`, `commit`, or any other command that mutates its own -working tree, index, or HEAD while a task is in progress. That distinction is the one a real -incident missed, where an agent reused a primary checkout as the working directory itself rather -than only as the source a worktree is created from. On a machine carrying the hub's agent-safety -install, Claude Code also makes this a mechanical stop for most of that list. -`merge --ff-only`/`pull --ff-only` stay exempt even there, and so does a `checkout `/`switch ` -naming exactly one positional that resolves as a ref, with no force flag and no `--` separator, -which is the shape this skill's own cleanup step runs. A `checkout -- .` or a `switch -c ` is -denied, being neither. Prose remains the only enforcement for a non-Claude-Code agent, for a -machine without that install, and for the shapes the hook itself exempts. +against it for that purpose, and outside two steps below, nothing else mutates its working tree, +index, or HEAD while a task is in progress: never `checkout`, `pull`, `reset`, `commit`, or any +other such command. Those two are "Listing and Cleanup"'s own terminal step and "Creating a +Worktree"'s return of the base clone to its own working branch where a continuation finds the task +branch checked out there. That distinction is the one a real incident missed, where an agent +reused a primary checkout as the working directory itself rather than only as the source a +worktree is created from. On a machine carrying the hub's agent-safety install, Claude Code also +makes this a mechanical stop for most of that list. `merge --ff-only`/`pull --ff-only` stay exempt +even there, and so does a `checkout `/`switch ` naming exactly one positional that +resolves as a ref, with no force flag and no `--` separator, which is the shape both of this +skill's own steps below run. A `checkout -- .` or a `switch -c ` is denied, being neither. +Prose remains the only enforcement for a non-Claude-Code agent, for a machine without that +install, and for the shapes the hook itself exempts. ## Creating a Worktree @@ -110,8 +112,8 @@ such a clone is never a push out of it: fork the upstream first, per the namespace above, under the name this one already has. ```sh -git -C ~/repos/ fetch origin develop -git -C ~/repos/ worktree add ~/repos/worktrees/- -b origin/develop +git -C ~/repos/"" fetch origin develop +git -C ~/repos/"" worktree add ~/repos/worktrees/"-" -b "" origin/develop ``` The registered worktree above is the normal path. It keeps the task visible in `git worktree @@ -159,10 +161,10 @@ and again in the handoff. Do not present work there as ordinarily reviewable fro workspace. ```sh -TASK_ORIGIN="$(git -C remote get-url origin)" -git clone --no-checkout "$TASK_ORIGIN" /- -git -C /- fetch origin develop -git -C /- switch -c origin/develop +TASK_ORIGIN="$(git -C "" remote get-url origin)" +git clone --no-checkout "$TASK_ORIGIN" "/-" +git -C "/-" fetch origin develop +git -C "/-" switch -c "" origin/develop ``` Do not use a linked worktree under the temporary root when the base clone's Git metadata is @@ -173,17 +175,27 @@ registered path and its approval route are unavailable. A continuation attaches the task's existing branch rather than forking a fresh one: ```sh -git -C ~/repos/ fetch origin -git -C ~/repos/ worktree add ~/repos/worktrees/- +git -C ~/repos/"" fetch origin "" +git -C ~/repos/"" worktree add ~/repos/worktrees/"-" "" ``` When the base clone holds only the remote-tracking ref, the same command creates the local branch tracking `origin/` through git's ordinary checkout guessing, so a fresh clone needs no separate branch setup. Git refuses to attach a branch that is already checked out somewhere else, and that refusal is the mandate working, since the branch sitting checked out in a shared -tree is the hazard the continuation rule exists for. Return that checkout to its own working -branch first when its tree is clean, and stop when it is not, because a dirty tree there may be -another task's uncommitted work. +tree is the hazard the continuation rule exists for. `git worktree list` names the checkout +holding it and prints the base clone first, and what to do there depends on which checkout that is. + +The base clone holding it is the case "The Base Branch" excepts. Return it to its own working +branch when its tree is clean, `git -C ~/repos/ checkout develop`, and then create the +worktree. Stop when it is not clean, because a dirty shared checkout is the signal that another +task may be live there. + +A previous session's own worktree holding it is retired rather than switched. It normally sits at +the very `~/repos/worktrees/-` path the command above wants, so `worktree add` +aborts on the existing directory whatever its branch is. Remove it when it is clean, and stop when +it is not, because a dirty tree there may be uncommitted work. `backlog-burndown` calls this +retire-then-dispatch and cites this skill for it. A machine not yet migrated to this layout still isolates exactly the same way, since the mandate is the isolation rather than the path: create the worktree beside whatever layout the machine diff --git a/.github/skills/resync-a-repo/SKILL.md b/.github/skills/resync-a-repo/SKILL.md index 2bd1767..82588b8 100644 --- a/.github/skills/resync-a-repo/SKILL.md +++ b/.github/skills/resync-a-repo/SKILL.md @@ -7,9 +7,11 @@ description: >- with the hub, or to run a conformance sweep against a named repo and apply what it finds. Needs a hub checkout and a named target repo to mean anything, so it does not usefully trigger from inside a downstream repo's own session with no target named and no hub checkout present, that - case is fleet-conformance-check instead. Triggers even when the request sounds routine, such as - "just copy AGENTS.md over" or "make repo X match the hub," because that phrasing is exactly how - the AGENTS.md-overwrite incident happened. + case is check-this-repo instead. Triggers even when the request sounds routine, such as "just + copy AGENTS.md over" or "make repo X match the hub," because that phrasing is exactly how the + AGENTS.md-overwrite incident happened. It co-fires with `carried-instruction-file-guard` and + `copilot-instructions-keeper`, which guard each carried file's merge, rather than replacing + them. --- # Resync a Repo @@ -29,9 +31,9 @@ change from one resync to the next. Read RESYNC.md section 0. A repo with no instruction set at all, or a partial one, is not this skill's job, it is STANDUP.md sections 1A and 2 instead, since an absent carried file is a -baseline that never arrived rather than drift to converge. Run `spec/audit.py `, the -target's `registry/repos.json` `name` field rather than an `owner/repo` slug or a checkout path, -and read whether the findings are letters (absent) or drift (present but stale) before doing +baseline that never arrived rather than drift to converge. Run `python3 spec/audit.py `, +the target's `registry/repos.json` `name` field rather than an `owner/repo` slug or a checkout +path, and read whether the findings are letters (absent) or drift (present but stale) before doing anything else. The finding kind names the procedure the repo is owed. ## Reach the hub and measure before changing anything @@ -48,9 +50,15 @@ to govern goes uncaught. A compound command is judged whole, so a copy chained t write does not run either. Prose is the enforcement here, and following it is not optional. Verify the host with `python3 scripts/host_gate.py --repo `, run from your hub worktree, since `scripts/` is hub-hosted and no carrier holds it. Then run the audit end to end, -`RESYNC.md` section 2, against the target's `main` branch, never `develop`. A finding is a -snapshot, so quote the run stamp in anything derived from it and re-run before acting on a finding -read earlier in the session. File any hub defect this work exposes against +`RESYNC.md` section 2. `python3 spec/audit.py ` measures the target's ground-truth +branch, which the registry's `groundTruthBranch` names and which is `main` for every cataloged +repo today, never `develop`. That is the invocation "Confirm the procedure before starting" +already ran, run again here. Convergence lands on a ref `main` does not hold yet, so a run that +previews in-flight work names that ref: `python3 spec/audit.py --branch `. Use it +to check whether a fix landed, since a run against `main` still reports what the in-flight ref +already fixed. Section 2 carries two further commands that neither of these replaces. A finding is +a snapshot, so quote the run stamp in anything derived from it and re-run before acting on a +finding read earlier in the session. File any hub defect this work exposes against `ptr727/ProjectTemplate`. Examples include bugs, conflicting sources, unclear or incomplete instructions, missing capabilities, and Copilot findings about any of them. Search open and closed issues first, then update the matching issue or file a new one. Preserve the evidence `RESYNC.md` @@ -71,7 +79,7 @@ repo, or agent memory. 5. **Settings, rulesets, and secrets.** Run `repo-config/configure.sh check "/" release` (substitute `operational` for an operational repo) from the hub at `main`, then `apply` for what it reports, never from a - carried copy. Run `spec/audit.py [RepoName]` from the same checkout for secrets. + carried copy. Run `python3 spec/audit.py ` from the same checkout for secrets. 6. **Intent files last, and by hand,** since nothing mechanical judges these. Reconcile the registry entry (`status`, `types`, `releaseTrigger`, `workflowModel`, @@ -83,6 +91,7 @@ rather than leaving it standing. One focused pull request per drift class, branched from the target's `develop`, never a direct push to a protected branch and never a hand edit outside a pull request. Close the review loop, per the `pr-review-conduct` skill, before asking the maintainer for merge permission. The -maintainer merges, the agent drives to green and stops. Re-run the audit after the merge and -commit the report once authorized, per `git-commit-conventions`, done means measured, not -applied. +maintainer merges, the agent drives to green and stops. Re-run the audit after the merge and, +from the hub checkout, commit the report under the hub's own `reports/` once authorized, per +`AUDIT.md` section 8 and `git-commit-conventions`. A session resyncing its own repository +leaves the report to a hub-side audit instead. Done means measured, not applied. diff --git a/.github/skills/session-handoff/SKILL.md b/.github/skills/session-handoff/SKILL.md new file mode 100644 index 0000000..d0504cb --- /dev/null +++ b/.github/skills/session-handoff/SKILL.md @@ -0,0 +1,323 @@ +--- +name: session-handoff +description: >- + Writes and resumes the ptr727/ProjectTemplate fleet's session handoff, a link in a chain of + issues rather than a file: one open issue per track per repository, carrying the `handoff` label + and naming its predecessor, which is then commented on and closed. Use this whenever ending a + session or a round of work, whenever starting or resuming one, whenever asked for a handoff or + for what the previous round did, whenever the maintainer says only "resume the handoff" or names + none, which stands for the whole attended procedure this skill states, and whenever about to + re-attempt something a previous round may already have tried, the moment that earns this skill, + since a session that does not know a chain exists never goes looking for one. Every session + writing one, except an `unattended-handoff` seat, ends in the same order: lessons recorded, the + link, the parked decision queue presented, then memories saved last. Triggers even when the + session feels too short to be worth a handoff, because the rounds that produce nothing worth + writing down are exactly the rounds a later session repeats. The rule is `AGENTS.md` "Session + Scope" and the mechanics are the hub's `scripts/handoff.py`. Where a sibling skill owns the + moment, it wins: `backlog-burndown` owns a multi-round run's reporting, `unattended-handoff` + owns a run with no maintainer present, and `repo-worktree` owns the worktree the handoff names. + `agent-conduct` co-fires at the parked-decision obligation and neither defers to the other. +--- + +# Session Handoff + +## Why This Exists + +A handoff written to a scratch file fails three ways the chain closes. The file is not found where +the next session looks. More than one candidate file is found and nothing says which one is +current. And there is no history at all, so a later round re-runs a path an earlier round already +tried and already wrote down. The value of a handoff is precisely the record of what has already +been attempted, and the shape the handoff actually had, one overwritten file on one machine, is a +shape that cannot hold it. + +The chain closes each of those rather than making it impossible. The label and the metadata block +are what make a link findable from any machine, the one-open-per-track invariant is what answers +which link is current, and every closed link stays readable to every session after it. Where the +invariant is broken the chain refuses and says so, which is a state a reader can act on rather than +one that reads as an answer. + +The rule is `AGENTS.md` "Session Scope", which keeps it, and it names the handoff's sections and +sets the size rule over them. This skill is the judgment that rule cannot state: what actually earns +a place in each of those sections, what belongs somewhere durable instead, and how to read a handoff +without trusting the parts of it that have gone stale. + +## What a Handoff Is, and Is Not + +- **It points at the durable record rather than restating it.** A defect becomes an issue, a rule + becomes documented rule text, and a lesson becomes governance prose, per `GOVERNANCE.md` "Durable + Knowledge and Self-Improvement". The handoff names each of those in a line and a link. The one + exception is what a round attempted and what that cost, which has no home outside the chain, so + there the chain is the durable record rather than a pointer to one. +- **It records work to do next, and it is not itself backlog work.** It carries none of the labels + that classify work to be done, and every enumeration that ranks or counts the open backlog filters + the `handoff` label out, since a track in use always has an open link and counting it inflates the + backlog by one per track forever. +- **It is not a place to ask a question.** A question waiting on the maintainer is an issue carrying + the `decision` label. The handoff records that queue, and "The Parked Decision Queue" below + requires the session to put the questions themselves to the user in the same act, since a + question recorded and never asked is the failure that section exists for. + +## The Chain + +- **One open handoff issue per track per repository.** A track is a short kebab-case slug naming a + lane of work, and a session that names no track uses `default`. Parallel lanes each name their + own, so each closes its own predecessor and neither reads the other's state as current. +- **The title is for humans**, shaped `Session Handoff []: `, which `new` composes + from the track and the subject it is given, so what a session writes is the subject alone. + Nothing parses the title, so a maintainer is free to rename one. +- **The chain is machine-readable from one HTML comment on the body's last line**, rendered + invisible, so a retitled or hand-edited issue still chains: + ``. +- **Closing the loop runs in one order**: create the new issue, comment the forward link on the + previous one, then close the previous one. Creating first means a failure at any later step leaves + a discoverable new issue rather than a closed chain with no successor, and `link` is what finishes + a run that stopped between those steps. +- **The invariant a reader checks** is exactly one open issue carrying `handoff` for a given track in + a given repository, read from each issue's metadata block rather than from the label, since an + issue carrying the label and no block belongs to no readable track and blocks the count rather + than joining it. Two or more on one track is a defect to report, never one to resolve by picking, + and a run interrupted between the three steps above is the one shape of it that `link` settles + rather than a human. +- **Zero open on a track is two different states.** The track has never had a handoff, or its lane + was closed out and its newest link is closed. `new` chains onto the newest link either way, open + or closed, because starting a second chain beside one that exists orphans every link already + written. +- **The handoff lives in the repository holding the work the next session resumes.** A session that + spanned repositories writes it there and names the others in the state section "What Goes in the + Body" below describes, rather than filing one handoff per repository. + +## What Goes in the Body + +A fixed section order and fixed section names, the bold name opening each of items 1 through 8 +below being the heading the handoff writes. Item 9 is an HTML comment the tool writes rather than a +section anyone types. Fixed names are what let a reader find a given fact in the same place every +time and what let one round's section be compared against another's. `AGENTS.md` "Session Scope" sets the size rule, and it is per section: an entry +earns its place by being specific enough to change a later session's behavior, and a section ranks +what it keeps and drops whatever does not meet that bar. `handoff.py` warns above 12 KB and refuses +above 60 KB, the refusal being a backstop against a body GitHub would reject and the warning a hint +that the per-section rule broke several sections earlier. Neither is the rule. + +1. **Next steps, in priority order.** The most valuable section, and first for that reason. Each item + names what to do and what "done" looks like. At most a handful, ranked. A step blocked on + something says so and names the blocker below. +2. **External blockers.** Anything the next session cannot resolve on its own: a maintainer decision, + a third-party quota or outage, an upstream release, a credential, a reviewer bot that is not + running. Each names who must act, what unblocks it, and what is safe to do meanwhile. +3. **Internal dependencies.** The ordering constraints among the next steps, each stated as the + dependency and its reason, so a later session can re-rank when circumstances change rather than + following an order it cannot audit. +4. **State.** Branch, worktrees and whose they are, open pull requests, what merged, whether a + release was dispatched, whether the primary checkout is clean, and any other repository the + round touched, named so a resume knows to look there too. These are facts a resume re-derives, + listed so the resume knows what to re-derive. +5. **The parked decision queue.** The count and the ranked list "The Parked Decision Queue" below + requires, which also requires the questions to be presented in the same act rather than only + recorded. +6. **What the last round did.** Issues closed and filed, pull requests landed, and the peripheral + issues filed along the way. +7. **What not to repeat.** The section a file could never carry: paths explored that led nowhere, + approaches that failed and why, problems discovered during execution, and the cost of each where + it is known. An entry here is a claim about a specific attempt rather than a general lesson. +8. **New learnings.** Only what is not already durable somewhere else, each with a pointer to where + it was recorded. A lesson that belongs in governance goes to governance and appears here as one + line and a link. +9. **The metadata block**, which `scripts/handoff.py` writes and no author types. + +Sections 7 and 8 are what the chain exists for, and they are also the two most likely to be padded. + +A handoff issue outlives the session that wrote it and is readable by everyone who can read the +repository, which on a public one is everyone, so it quotes no data observed in the maintainer's +environment, per `GOVERNANCE.md` "Representative Data in Agent-Authored Text". That rule binds on a +private repository exactly as it does on a public one, and the visibility only changes who the +audience is. The routine case here +is an absolute home path, since a handoff naturally wants to name a worktree, and the rule for it is +to name the worktree by its branch and its repository-relative role rather than by its path. A next +session re-derives the path from `git worktree list` anyway, which section 4 already tells it to +do. + +## Resuming + +**Re-derive live state, and do not trust the handoff's copy of it.** Branches, worktrees, open pull +requests, and issue counts are read again from git and from GitHub at resume. The handoff's state +section is the pointer to what to read rather than the answer, and `AGENTS.md` "Session Scope" +already says stale context is worse than absent. A handoff is context by construction, so this is +that rule applied to the one artifact built to outlive the session that wrote it. + +Read the current link's comments too, with `gh issue view "" --comments`, since `resume` prints +only the body, and a parking comment or a closing session's answers land in the comments. + +Read the chain before re-attempting anything. `resume` prints the current body and indexes the +closed links behind it, and `chain --grep` searches the bodies it walks for a regular expression, +which is how a session answers whether a path has already been tried without reading every round. +Three things bound that answer. The walk starts at one track's newest link and follows each +marker's `previous=` from there, which is normally that one lane and is in fact whatever the markers +name, so every link prints its own track and a predecessor on another lane shows up rather than +passing unseen. The walk stops at `--limit` and names the link it stopped short of, so a capped +search never reads as an exhaustive one. And `--grep` takes a regular expression, so a literal +string carrying a regex character is escaped or it matches something other than what was typed. On +a track with no open handoff `resume` refuses rather than printing an empty body, and `chain` falls +back to that track's newest closed link, so the read side of a lane that was closed out is `chain` +rather than `resume`. + +Re-derive a count rather than copying one, and read it with an explicit page size. `gh issue list` +returns 30 rows unless told otherwise, and a truncated count reads exactly like a repository with +30 issues, which is worse than an absent count because it gets stated. + +## The Attended Session + +A maintainer resuming work says little, often only "resume the handoff", and that phrase stands for +the whole procedure below. Run every step without being reminded of any of them. + +1. **Pick the link.** Where the maintainer names an issue, take it. Where none is named, read + `gh issue list --label handoff --state open --limit 100 --json number,title,labels,updatedAt`, + since `tracks` prints neither labels nor exact update times. Take a link carrying `blocked` + first, newest update first among them, since its blocker is a decision only the maintainer can + make and the maintainer is now present. Otherwise take the newest update among the rest. An + `auto-*` link not carrying `blocked` is skipped unless named, since an `unattended-handoff` + worker may hold it right now, and where one is named, confirm with the maintainer that no + unattended run is live before working it. Where every open link is skipped, say so and ask the + maintainer which to take. Say which link was picked in one line before anything else, so a wrong pick costs one reply + rather than a round. +2. **Resume it** per "Resuming" above, comments included, re-deriving live state rather than + trusting the body. +3. **Ask what it is blocked on first.** Where the link carries `blocked`, the parking comment names + a `decision` issue. Read it first, since the maintainer may have answered it there already. + Otherwise that question goes to the maintainer before any work starts, since the rest of the + link waits on it. Once answered, record the answer on the decision issue, take its `decision` + label off, and close it where it held nothing but the question. Leave `blocked` on the handoff + while this session works the link, so a running `unattended-handoff` loop does not pick it up + underneath the session, and settle it in step 6. +4. **Work the next steps** in a worktree of the session's own, per `repo-worktree`, and drive each + pull request with `drive-pr` to a mergeable develop -> main promotion pull request. The phrase + already states that target, so `drive-pr` does not ask how far. Merging that promotion pull + request and dispatching a release stay `merge-and-release`, each on an explicit go-ahead asked + for as a prompt whose option names the action. +5. **Ask every question as a dialog.** Where the interface has a prompt mechanism, each decision is + its own question with its answers as the options, the recommended one first and marked as the + recommendation, and every one carrying its reason, per "The Parked Decision Queue" below. The + numbered list is the fallback where no prompt exists. +6. **Close the session** per "Closing a Session" below, which records lessons, writes the next link + with `new`, presents the parked decision queue, and saves memories last. An `auto-*` lane is the + exception, since it holds one issue. Where that issue is done, comment the outcome on the link + and close it with no successor, as `unattended-handoff` closes such a lane out. Where work + remains, write its next link on the same track without `blocked`, its next steps naming the + decision issue and the answer, which hands it back to the unattended loop. Either way the + `blocked` label left on in step 3 goes with the link it was on, and the parked decision queue + is still presented, since the exception covers only the link. + +## Closing a Session + +Every session that writes a handoff ends in this order, except an `unattended-handoff` seat, which +closes as that skill states. Each step leaves the chain whole if the session is interrupted after +it. + +1. **Record what was learned** per `GOVERNANCE.md` "Durable Knowledge and Self-Improvement", which + says where a lesson goes, before the link is written, so its "New learnings" section points at a + record that exists. +2. **Write the link**, per "Running the Chain" below, or close the lane out where its work is done. + The link is written before the closing questions are put, since a prompt blocks until someone + answers and an unanswered one must not cost the round its handoff. +3. **Present the parked decision queue** in the same act, per "The Parked Decision Queue" below. + Record each answer given on its issue, where one was asked and answered. Where a link was + written, comment those answers onto it together with the queue's count and list after them, + since the body keeps the count it was written with and a resume reads the link's comments. +4. **Save memories last.** Where the host keeps a per-user memory, what goes there is the + environment-specific nuance "Durable Knowledge and Self-Improvement" leaves to memory, such as a + quirk of this machine or this account, saved as the session's final act. Never the round's + state, which the link holds, and never a lesson, which step 1 already recorded. + +## The Parked Decision Queue + +The rules below are `GOVERNANCE.md` "Communicating with the User", carried whole so this skill works +in isolation. Two of them state the obligation: the one opening "A question filed as an issue is +parked rather than asked" and the one opening "The session that writes a handoff presents the parked +queue in the same act". Three more state the form the questions take, the one opening "Ask every +question through the interface's prompt, never in prose", which also names the numbered list as the +fallback where no prompt exists, the one opening "Raise work blocked on the user as a direct +interactive prompt", which shapes the options, and the one opening "Lead every choice with a +recommendation and its reason", which binds every question put either way. A reader who stops after +the obligation has it with no shape to put it in. + +Recording the queue is not asking it. The handoff's "The parked decision queue" section is the +recording half, and what the other half is depends on what the session can reach: a prompt where one +is available, the numbered list where a user is present and no prompt is, and nothing at all where +no user is present, in which case the handoff names the whole queue by issue number and the asking +falls to the next session that has one. The rules below settle which case applies. + + + +- **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. + +`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. + + + +## Running the Chain + +The mechanics are the hub's `scripts/handoff.py`, run from a hub checkout, per `GOVERNANCE.md` +"Hub-Hosted Tooling". Every subcommand takes `--repo`, with no default, because an issue number +resolves in every repository and a chain read out of the wrong one is well formed. `current`, +`resume`, `chain`, and `new` each take an optional `--track` defaulting to `default`, and `link` +and `tracks` take none, since a pair of issue numbers and a whole-repository survey each name +their own scope. A session working a named lane passes `--track` on each of the four that accept +it. Omitting it does more than read the wrong chain, since `new` then files onto `default` and +comments on whatever link that lane has, closing it too where it was open. + +- **`current` and `resume`** answer where a track stands, `resume` adding the body itself and an + index of the closed links behind it. +- **`chain`** starts at one track's newest link and walks `previous=` backwards from there, + following whatever the markers name and saying so where that leaves the track, `--grep` + filtering the listing by a regular expression over the bodies while the notices print + regardless. +- **`tracks`** surveys every open handoff, one carrying no metadata block included, and it is the + one command that reports that state rather than refusing over it. That state is settled by a + hand edit, adding the block to the issue's body or taking the label off it, since no read can + place an issue on a track until its own block says which track that is. +- **`new`** performs as many of the chain's three steps as the track has links for. On a track + with no link at all it creates and stops, since there is nothing to comment on or close. On one + whose newest link is closed it creates and comments, since the close already happened. Only a + track with an open head takes all three. **`link`** finishes a run that stopped between them, + and separately repairs a successor whose block names no predecessor, so its own writes are the + comment, the close, and that body edit. The edit reaches only the repair case, since `new` + embeds the predecessor in the block at create time, so a run interrupted after the create + already names it. + +```sh +python3 scripts/handoff.py current --repo OWNER/NAME --track "" +python3 scripts/handoff.py resume --repo OWNER/NAME --track "" --history 5 +python3 scripts/handoff.py chain --repo OWNER/NAME --track "" --grep "an escaped regex" +python3 scripts/handoff.py new --repo OWNER/NAME --track "" --title "" \ + --body-file "" --dry-run +python3 scripts/handoff.py link --repo OWNER/NAME --new "" --previous "" +python3 scripts/handoff.py tracks --repo OWNER/NAME +``` + +Read `--dry-run` output before the first real `new` of a session, since the run can close an issue. +It is accepted by `new` and `link`, the two subcommands that write, and by no other. + +Exit `0` is success, `1` a refusal the caller can act on, a usage error included, and `2` the +command not having run to an answer, so a refusal and a failure to reach one never share a code. A +repository missing the `handoff` label is a refusal rather than a degraded empty answer, and it +names the command that applies the fleet label set except where the label read filled its window, +which is the one case where the label's absence is unproven rather than established. + +Creating an issue, commenting on one, closing one, and editing a body are each outward-facing +writes. `new` creates, comments, and closes, the label riding inside the one create call rather than +being a write of its own. `link` edits a body, comments, and closes. Each of them is bound by +`GOVERNANCE.md` "Repository Boundaries and Write Safety" exactly as any other write is. Point them +at the repository `AGENTS.md` "Session Scope" sends the link to, the one holding the work the next +session resumes, and at no other. `link` also reaches an issue this chain never created, since the +caller names both numbers and its refusals ask for a block and a label to be added by hand first, so +the two issues it is given are chosen deliberately rather than swept up. + +Where the caller names an issue, which is `link` alone, it reads both live before writing and +writes only what those reads returned. Every other identifier a write targets is captured from a +read in the same run, and the one identifier no read could have supplied, the new issue's own +number, is parsed from the create's confirmation rather than constructed. No write's output is suppressed or forced to success, and a close is +confirmed by reading the state back, because a write that appears to have failed may have succeeded +on the server. diff --git a/.github/skills/skill-lifecycle/SKILL.md b/.github/skills/skill-lifecycle/SKILL.md index aa85391..e433685 100644 --- a/.github/skills/skill-lifecycle/SKILL.md +++ b/.github/skills/skill-lifecycle/SKILL.md @@ -1,7 +1,7 @@ --- name: skill-lifecycle description: >- - Governs the lifecycle of the fleet's own skills in ptr727/ProjectTemplate: creating, changing, splitting, and retiring a skill under .agents/skills/, the source-versus-generated split with .github/skills/ and .claude-plugin/, the regenerate and --check semantics of scripts/build_dist.py, the install and stamp semantics of scripts/skills_install.py, the doc-packaging pattern that keeps a law doc and its skill in agreement, and the trigger-description conventions that make a skill fire. Use this whenever about to create, edit, move, or delete anything under .agents/skills/, .github/skills/, or .claude-plugin/, whenever packaging a doc or a doc section as a skill, and whenever deciding whether a topic deserves a skill at all. Triggers even when the edit looks trivial, such as fixing a typo in one SKILL.md, because the generated distributions desync the moment the source changes without a build_dist.py run, and CI fails the pull request on exactly that. Hub-context only, since .agents/skills/ exists only in the hub. + Governs the lifecycle of the fleet's own skills in ptr727/ProjectTemplate: creating, changing, splitting, and retiring a skill under .agents/skills/, the source-versus-generated split with .github/skills/ and .claude-plugin/, the regenerate and --check semantics of scripts/build_dist.py, the include regions it fills from a rule's home so a skill carries the rule's text without a copy, the install and stamp semantics of scripts/skills_install.py, the doc-packaging pattern that keeps a law doc and its skill in agreement, and the trigger-description conventions that make a skill fire. Use this whenever about to create, edit, move, or delete anything under .agents/skills/, .github/skills/, or .claude-plugin/, whenever packaging a doc or a doc section as a skill, and whenever deciding whether a topic deserves a skill at all. Triggers even when the edit looks trivial, such as fixing a typo in one SKILL.md, because the generated distributions desync the moment the source changes without a build_dist.py run, and CI fails the pull request on exactly that. Hub-context only, since .agents/skills/ exists only in the hub. --- # Skill Lifecycle @@ -12,10 +12,12 @@ The agent most likely to get a skill wrong is the one editing a skill, and befor ## The Pipeline -- **`.agents/skills//SKILL.md` is the only hand-authored source**, with optional `references/` and `scripts/` directories beside it. Codex and opencode read this tree directly, project-local, and also read the global `~/.agents/skills/` copy the installer materializes. +- **`.agents/skills//SKILL.md` is the only tree a skill is authored in**, with optional `references/` and `scripts/` directories beside it, and the one part of it not written by hand is the text inside an include region, described below. Codex and opencode read this tree directly, project-local, and also read the global `~/.agents/skills/` copy the installer materializes. - **Generated distributions serve GitHub Copilot and Claude Code.** `scripts/build_dist.py` generates `.github/skills/` for GitHub Copilot and a Claude-plugin-compatible copy at `.claude-plugin/fleet-skills/`, published through `.claude-plugin/marketplace.json`. Neither generated tree is hand-edited, and `build_dist.py --check` exits non-zero when either tree differs from `.agents/skills/`. - **The skill set is implicit.** Every `.agents/skills//` directory carrying a `SKILL.md` is a skill, and the generated `plugin.json` derives its list from those directories, so adding or retiring a skill edits no manifest by hand. `marketplace.json` names the plugin, not the skills, and is untouched by ordinary lifecycle work. -- **`scripts/skills_install.py`, run from a hub checkout, installs both forms per machine**: an overlay copy into `~/.agents/skills/` for Codex and opencode, marked per skill so a retired skill is removed on the next run and a foreign skill is never touched, and a user-scope plugin install for Claude Code via the `claude` CLI. Each run stamps the hub commit into `~/.agents/skills-install-stamp.json`, and `--report` reads that stamp against the checkout and exits non-zero when the machine is behind. The install is global per user, and per-repo pinning is a settled non-goal (`docs/fleet-map.md` "Skills Install Model"). +- **A rule's text reaches a skill as a generated include, never as a copy.** A region opened by a line holding only `` and closed by a line holding only ``, each indented at most three spaces, is filled by `build_dist.py` with the body under that heading. The key is the root-relative path, spelled as the tree spells it, then ` > `, then the heading text at any level from two, matched case-insensitively. The body runs to the next heading at that level or above, level one included, so a key naming a level-two heading carries every subsection under it. The fill lands in `.agents/skills/` itself, since Codex and opencode read that tree directly and a region left empty there is a skill with a hole in it, and the generated trees mirror the filled source. A region is filled only in a file the generator walks, which is the `*.md` files under each `.agents/skills//` directory carrying a `SKILL.md`, plus the files `build_dist.py`'s `INCLUDE_DESTINATIONS` tuple declares: a Markdown file sitting in `.agents/skills/` but not under such a directory, `.agents/skills/README.md` among them, is walked no more than a file elsewhere is. That tuple is how a hub surface that is not a skill carries a rule's text rather than restating it, each entry declared by hand and held to the same path rules a source is held to, and a file `spec/files.json` carries content from is refused. It is empty today, because the surfaces that would qualify restate a rule in their own words rather than copying it and a pointer is what each of those needs, so `scripts/tests/test_build_dist.py` rather than any shipped declaration is where the shape of one is exercised. A source is any regular file under the repository root outside the two generated trees and reached through no symlink, so a key may name a `GOVERNANCE.md` section, an `AGENTS.md` subsection, or a section of a sibling skill, and a region filled from a file carrying regions of its own reads that file's filled text. Regenerating after a source edit changes the bytes of every skill unit including it, and `--check` fails the pull request until that regenerate runs. Those units then read as moved past the pass that covered them, which is the periodic sweep's work rather than this change's, per `GOVERNANCE.md` "Verification Discipline". +- **`--check` holds every region to its source.** It fails when a region differs from what its source renders now, so a hand edit inside one and a source edit nobody regenerated for both fail the pull request the same way a stale mirror does. `python3 scripts/build_dist.py`, with no flag, is what clears either. A failure regenerating cannot repair is exit 2 rather than 1, whether or not a region is what it is about: a key with no ` > ` or an empty heading, a path naming no file, a heading that no longer resolves or that recurs in its source, a body with nothing in it or leaving a code fence open, a region in a file the generator does not walk, reached through a key, a declared destination refused by the same path rules a source is held to, or carried by the manifest, a `spec/files.json` that cannot be read or parsed while a destination is declared, since a check that cannot run must not wave one through, a symlink at or under a skill's own directory, a region that opens inside another or never closes, a close marker with no region open, a cycle, a path that is empty, absolute, carries a `..` component, or is spelled otherwise than the tree spells it, one outside the root, through a symlink, or under a generated tree, a file that is not UTF-8 wherever the run decodes one, a file holding a region while mixing line endings, and a line outside a code block that begins like a marker and matches neither form, which read as content would leave a region unfilled. An unreadable file is exit 2 as well, and it is the one cause that is not a refusal: `--check` catches the operating system's own error beside the refusals, so a permissions failure or a file removed mid-run is reported rather than read as a stale result. Every code here is `--check`'s. The no-flag run reports a refusal as exit 1. +- **`scripts/skills_install.py`, run from a hub checkout, installs both forms per machine**: an overlay copy into `~/.agents/skills/` for Codex and opencode, marked per skill so a retired skill is removed on the next run and a foreign skill is never touched, and a user-scope plugin install for Claude Code via the `claude` CLI. Each run stamps the hub commit into `~/.agents/skills-install-stamp.json`, and `--report` answers the two channels separately: which commit the copy was taken from, judged against the promoted `main`, and which branch and commit the checkout Claude Code loads in place is serving now. It exits non-zero when the copy is not current. The install is global per user, and per-repo pinning is a settled non-goal (`docs/fleet-map.md` "Skills Install Model"). ## Deciding a Topic Deserves a Skill @@ -24,26 +26,27 @@ A skill surfaces at a trigger moment. A rule that binds every action all the tim ## Creating a Skill 1. **Name the directory in kebab-case** and set the frontmatter `name:` to the same string. -2. **Write the `description:` to carry the trigger**, since it is the only part an agent reads before deciding to load the skill: state what the skill governs, then the concrete moments it applies ("Use this whenever..."), then the routine phrasings that precede the failure it guards against ("Triggers even when..."), naming a real incident where one exists. Disambiguate against sibling skills by name, the way `standup-a-repo`, `resync-a-repo`, and `fleet-conformance-check` each state which of the three a session is in. +2. **Write the `description:` to carry the trigger**, since it is the only part an agent reads before deciding to load the skill: state what the skill governs, then the concrete moments it applies ("Use this whenever..."), then the routine phrasings that precede the failure it guards against ("Triggers even when..."), naming a real incident where one exists. Disambiguate against sibling skills by name, the way `standup-a-repo`, `resync-a-repo`, and `check-this-repo` each state which of the three a session is in. 3. **Author the body per the `comment-and-doc-style` skill**: LF (the repo default), present tense, ASCII tiers, no semicolon in prose. Name hub paths as plain code spans rather than repo-relative links, because an installed copy resolves no repo path, and say "from a hub checkout" for anything the reader must run. 4. **Split bulk into `references/`** when the source doc is large: the SKILL.md carries the summary and the binding rules, and each `references/*.md` carries one topic read on demand, the shape `comment-and-doc-style` uses. 5. **Apply the doc-packaging pattern below in the same change** when the skill packages a law doc or one of its sections. 6. **Regenerate and commit all trees together**: `python3 scripts/build_dist.py`, then, once authorized, commit the source and both generated trees in one commit, per `git-commit-conventions`. CI runs `--check` on every pull request and fails a desynced distribution. `python3 scripts/tests/test_build_dist.py` covers the generator itself. 7. **Record the surfacing**: annotate the `AGENTS.md` "Where the Rules Live" row when the skill packages a GOVERNANCE section, or its closing paragraph when the skill is new content, so the map stays the one place coverage is read from. -8. **Refresh the machines after merge**: re-run `python3 scripts/skills_install.py` per machine, the cadence `docs/host-setup.md` "Fleet Skills Install" states. Until then every machine serves the previous skill set, which `--report` says. +8. **Refresh the machines after promotion**: re-run `python3 scripts/skills_install.py` per machine, from a freshly fetched `main`, the cadence `docs/host-setup.md` "Fleet Skills Install" states. Until then each machine's Codex and opencode copy holds the previous skill set, which `--report` says, while Claude Code serves whatever the hub checkout holds. ## Changing or Retiring a Skill -- **Edit only the source tree.** Any skill-content change under `.github/skills/` or `.claude-plugin/` that did not come from a `build_dist.py` run is a defect, whatever it fixes. -- **Retiring is deleting the source directory and regenerating.** The derived `plugin.json` list shrinks with it, and the installer's per-skill markers remove the retired skill from `~/.agents/skills/` on each machine's next run. +- **Edit only the source tree, and outside its include regions.** Any skill-content change under `.github/skills/` or `.claude-plugin/` that did not come from a `build_dist.py` run is a defect, whatever it fixes. The text inside an include region is generated too, so a change there is made at the region's source and regenerated, never typed into the region. +- **Retiring is deleting the source directory and regenerating.** A region in a sibling keyed on the retired skill stops that regenerate, since its key no longer resolves, so re-key or remove it first. The derived `plugin.json` list shrinks with it, and the installer's per-skill markers remove the retired skill from `~/.agents/skills/` on each machine's next run. - **A deletion sweeps the prose that references the skill**, in the same change rather than as follow-up: the `AGENTS.md` map row or paragraph naming it, any law-doc packaging pointer to it, and any sibling skill that disambiguates against it. A law-doc section that had moved its full rules into the skill takes them back, or is retired with it, so no rule is silently lost with the skill that carried it. -- **Renaming is a retire plus a create** as far as the installer's markers and the plugin list are concerned, so sweep references the same way. +- **Renaming is a retire plus a create** as far as the installer's markers and the plugin list are concerned, so sweep references the same way. An include key spelling the old path is such a reference, and one left behind fails `--check` as a region it cannot render rather than as a stale mirror. ## The Doc-Packaging Pattern -Packaging keeps one topic in one authoritative place while the skill makes it surface automatically. It has two shapes, and each pairing states which it uses: +Packaging keeps one topic in one authoritative place while the skill makes it surface automatically. It has three shapes, and each pairing states which it uses: - **Moved content.** The law-doc section keeps a summary and the skill holds the full rules (`git-commit-conventions`, `comment-and-doc-style`, `pr-review-conduct`). The section ends with the standard pointer sentence: packaged as the named skill at `.agents/skills//SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, read the skill for the full rules. -- **Kept authority.** The source doc keeps the full rules and the skill is the summary that routes to them (`audit-a-repo` over `AUDIT.md`, `workflow-ci-contract` over `WORKFLOW.md`, `agent-conduct` over its GOVERNANCE sections). The skill states per topic which doc section owns it. +- **Kept authority.** The source doc keeps the full rules and the skill is the summary that routes to them (`audit-a-repo` over `AUDIT.md`, `workflow-ci-contract` over `WORKFLOW.md` outside sections 3, 4, and 5). The skill states per topic which doc section owns it. +- **Included content.** The doc keeps the full rules and the skill needs them whole to work in isolation, so it carries the section as a generated include rather than as a summary or a copy, declared with the region markers "The Pipeline" above describes and keyed on the doc's section (`agent-conduct` over the three `GOVERNANCE.md` sections it surfaces, `workflow-ci-contract` over `WORKFLOW.md` sections 3, 4, and 5). The doc side states the shape with one sentence naming the skill that includes the section, and the skill side is the region itself. A section carried this way is read outside its own document, so it names a sibling section by document and heading rather than as above or below, and it links to no file by a relative path, since the path would resolve against the skill's directory rather than the doc's. The doc wins by construction, since `scripts/build_dist.py` writes the region from it and its `--check` reports a region that differs from it as stale. -In both shapes the doc wins on any disagreement, and the skill is what needs fixing. A rule stated fully in both places is the drift this pattern exists to prevent, so an edit to a packaged rule lands in its owning place and the other side's summary is checked against it in the same change. +In every shape the doc is the authority when the two are found to disagree, the moved-content shape included: the doc's summary says what the rule is, and the skill's full text is what gets corrected. A deliberate change to a packaged rule is not such a disagreement. It lands where the full text lives, and in the same change the author either edits the other side's summary to match, since a summary has no mechanical check, or regenerates the include, which has one. A rule stated fully in both places by hand is the drift this pattern exists to prevent, and an include is the one full second statement that cannot drift undetected. diff --git a/.github/skills/standup-a-repo/SKILL.md b/.github/skills/standup-a-repo/SKILL.md index 363d6bb..61fc1e0 100644 --- a/.github/skills/standup-a-repo/SKILL.md +++ b/.github/skills/standup-a-repo/SKILL.md @@ -7,7 +7,7 @@ description: >- bootstrap, or onboard a new fleet repo, or to onboard a new repo type. Needs a hub checkout and a target repo, new or partially started, to mean anything, so it does not usefully trigger inside an already-operational downstream repo's own session with no hub checkout present, that - case is resync-a-repo for drift or fleet-conformance-check for a self-check instead. Triggers + case is resync-a-repo for drift or check-this-repo for a self-check instead. Triggers even when the request sounds like "just copy the template over" or "spin up a quick repo," because skipping the ordered signing, branch, and instruction-set steps below is exactly how a repo ends up unsigned, unrecoverable, or authored against unknown rules. diff --git a/.github/skills/unattended-handoff/SKILL.md b/.github/skills/unattended-handoff/SKILL.md new file mode 100644 index 0000000..b6bf958 --- /dev/null +++ b/.github/skills/unattended-handoff/SKILL.md @@ -0,0 +1,250 @@ +--- +name: unattended-handoff +description: >- + Runs a ptr727/ProjectTemplate fleet repository's handoff chain with no maintainer present: a + lean orchestrator loops, dispatching a picker subagent that returns one handoff whose work needs + no maintainer decision, creating that handoff from the open backlog where none is waiting, then + a worker subagent that resumes the handoff, fixes it, drives its pull request as far as the + invocation's scope allows, and closes the lane out, or parks it when a decision turns up, + leaving the branch open, a state comment on the handoff, a `decision` issue for the maintainer, + and the `blocked` label on the handoff. Use this whenever asked to run the handoff loop + unattended, work the auto-resolvable issues while the maintainer is away, keep going until + nothing is left that needs no decision, or run handoffs overnight. Triggers even when the + backlog looks small, because the failure it guards against is an orchestrator that reads issues, + diffs, and review threads itself and exhausts its context after a few rounds. Scope is named at + invocation: develop by default, main to also merge each promotion pull request, release to also + dispatch the release. Distinct from `backlog-burndown`, which runs parallel groups with the + maintainer reachable, and from `session-handoff`, which owns the chain's shape and the attended + "resume the handoff" session this loop's parked links return to. Its invocation scope is the + explicit go-ahead `merge-and-release` otherwise asks for. +--- + +# Unattended Handoff + +## Why This Exists + +The attended session, `session-handoff`'s "The Attended Session", needs the maintainer for every +decision it meets. Most open issues need none, so a loop can work those while the maintainer is +away and hand back only the ones that do. Two things make that loop hard. Its orchestrator runs for +hours, so any detail it reads is paid for again on every later round, and a loop that reads issues +itself dies of its own context long before the backlog is empty. And a worker that meets a decision +must stop without losing its work, in a state the maintainer's next attended session picks up with +no reminder. + +## The Three Seats + +- **The orchestrator** is the session this skill is invoked in. It resolves the repository once, + then only dispatches, reads one line back, and dispatches again. It reads no file, issue, diff, + or review, writes no handoff, and saves no memory. Everything it would learn by looking belongs + to a subagent that starts empty and is discarded after one round. Its cost stays flat because of + what it holds, whatever tier it runs on. +- **The picker** is a subagent dispatched once per round. It chooses one handoff whose work needs + no maintainer decision, creating one where none is waiting, and returns its number and track. +- **The worker** is a subagent dispatched once per round on the handoff the picker returned. It + does the work in its own worktree and ends the handoff as done or parked. + +One worker runs at a time, so no two rounds claim the same file and no round needs the grouping +`backlog-burndown` does. + +## Invocation and What It Authorizes + +The maintainer names the scope when invoking the skill, and the scope is the whole grant. + +| Scope | A worker may merge | +| --- | --- | +| `develop`, the default | its feature -> develop pull request | +| `main` | that, then the develop -> main promotion pull request that follows it | +| `release` | both, then dispatch the release that promotion unblocks | + +The default keeps every main merge the maintainer's. `main` promotes each fix alone, since many +develop merges queued behind one promotion make that promotion too large to review. `release` +exists because a merge to main with no release never exercises artifact creation. + +- **The grant is bounded by the session it was named in**, as `backlog-burndown`'s "What Invoking + This Skill Authorizes" bounds its own. A run resumed in a new session needs the scope named again. +- **Under `main` or `release` it is the one standing promotion grant** `merge-and-release` + recognizes, covering each promotion this run's workers make, since naming the scope is naming + every such merge in advance. +- **The grant answers the explicit-permission item of the `pr-review-conduct` Merge Gate for the + merges its scope names, and nothing else.** A pull request with an open finding still does not + merge, and no scope authorizes closing an issue on judgment, changing repository settings, or + touching another repository. +- **The orchestrator passes the scope into every worker brief.** A brief is not a grant, per + `drive-pr`, and what makes the merge authorized is the maintainer having named the scope in this + session. + +A run may also be given a round cap. Where none is named, it is 20. + +**One run per repository at a time.** The picker reads an open `auto-*` handoff not carrying +`blocked` as a lane whose worker died, which only holds while no other run is live, so the +maintainer starts a second run on a repository only once the first has ended. + +## The Loop + +The orchestrator first resolves `/` from the checkout's `origin`, or takes it from the +invocation, which is the one command it runs. From then on it holds exactly four things: the scope, +the round count, the handoff numbers dispatched so far, and the one-line outcome of each round. +Each round runs: + +1. **Dispatch the picker** with the brief below, and wait on the dispatch mechanism's own + completion signal rather than polling. +2. **Read its one line.** `NONE` or `STOP` ends the run. A handoff number already dispatched in + this run ends it too, since a handoff a worker neither closed nor parked means the worker failed + in a way this seat must not investigate. +3. **Dispatch the worker** on that handoff and track, at the tier the picker named, and wait the + same way. +4. **Record its one line** and start the next round. `STOP` ends the run, and so does a reply that + is not one of the lines below, rather than being read further. + +The run ends at `NONE`, at `STOP`, at the round cap, or at the repeat stop above. Its final message +lists every round's outcome line, then the count of parked handoffs, and names the attended session +(`session-handoff`, "resume the handoff") as where they get answered. It writes nothing else +and asks nothing. + +### The Briefs + +Pass these verbatim, filling the angle brackets. They name this skill rather than restating it, +which keeps the orchestrator's own context to the brief's length. + +```text +Load the `unattended-handoff` skill and act as its picker for /, +scope . Reply with exactly one line, in the picker return form that skill states. +``` + +```text +Load the `unattended-handoff` skill and act as its worker on handoff #, track , +in /, scope . The maintainer named that scope when +invoking the run. Reply with exactly one line, in the worker return form that skill states. +``` + +### Return Lines + +| Seat | Line | Means | +| --- | --- | --- | +| picker | `PICK # track= tier=` | work handoff `#` on that model tier | +| picker | `NONE ` | nothing left that needs no decision | +| worker | `DONE # ` | merged as far as the scope allows, lane closed out | +| worker | `PARKED # decision #` | parked on decision issue `#` | +| either | `STOP ` | a condition no later round can clear, so the run ends | + +`STOP` is for a state of the repository or the session rather than of one issue: a missing label, +an exhausted reviewer quota, a push the executor refuses, or a promotion pull request already +waiting on an open `decision` issue, since every later round would meet that same decision. + +A promotion carries whatever develop holds, since that is what a develop -> main pull request is. +Under `main` or `release` every round promotes, so each one ordinarily carries one fix, and a change +another session merged to develop meanwhile rides along with it. Naming the scope accepts that. + +## Auto-Resolvable + +An issue needs no maintainer decision when every one of these holds. Where any is unclear, it +does not qualify, since skipping one costs nothing and a guess costs a revert and a review round. + +- **The right outcome is determined** by the issue together with the committed rules, and the issue + leaves no choice open between alternatives it names. +- **Nothing on it waits on the maintainer.** It carries none of `decision`, `blocked`, `handoff`, or + `canonical-sweep`, the last being an issue a workflow owns rather than one a pull request closes, + and no comment asks the maintainer something still unanswered. +- **The fix stays inside this repository's tree.** It changes no repository setting, ruleset, + visibility, secret, or release condition, and needs no credential, account, or host the session + lacks. +- **It reverses no settled decision** recorded in an issue, a handoff, or the rule text. +- **Nothing has worked it or is working it.** The track `auto-` has no link, open or closed, + which `handoff.py chain --track "auto-" --limit 1` answers with its refusal naming no + handoff on that track. Any other refusal from it is a `STOP` rather than a yes. No open pull + request names it, and no pull request whose squash commit is in `origin/main..origin/develop` + names it anywhere in its body, since a fix merged to develop leaves its issue open until it is + promoted, whoever merged it. No open handoff on any track names it in its next steps, and no + comment on it claims it for a `backlog-burndown` group, since both mark work that has no pull + request yet. + +## The Picker + +1. **Check the promotion first** under `main` or `release`. Where an open `decision` issue names + the open develop -> main pull request, return `STOP` before picking anything, since every worker + this run dispatched would meet that same decision after merging its own work to develop. +2. **Read the open handoffs** with labels and update times, `gh issue list --label handoff --state + open --limit 100 --json number,title,labels,updatedAt`, since `handoff.py tracks` prints + neither. Reach `scripts/handoff.py` from a hub checkout, per `session-handoff` "Running the + Chain". +3. **Prefer an open `auto-*` handoff not carrying `blocked`**, oldest first. That is a lane an + earlier run parked and the maintainer has since unblocked, or one whose worker died, and a live + link is work already framed. Handoffs on any other track belong to the maintainer's attended + lanes and are never picked. A picker never takes `blocked` off a handoff, even where the decision + issue it names has been answered, since an attended session may be working that lane and only + the session handing the lane back removes the label, per `GOVERNANCE.md` "Durable Knowledge and + Self-Improvement". +4. **Otherwise pick from the backlog.** Rank the open issues by `backlog-burndown`'s "Ranking" + criteria, keep the auto-resolvable ones, and take the top one. Read the list with an explicit + page size, since `gh issue list` returns 30 rows unless told otherwise. +5. **Create its handoff** with `handoff.py new --track "auto-"`, `--dry-run` first. The body + carries the sections `session-handoff` "What Goes in the Body" names, with the next steps naming + the issue and what done looks like. That skill's rules on the body bind it. +6. **Choose the worker's tier** by `backlog-burndown`'s "Choosing the Worker's Model Tier". +7. **Reply with one line.** A picker writes nothing but the handoff it creates, and returns `STOP` + where a read it needs cannot run. + +## The Worker + +1. **Resume the handoff** with `handoff.py resume --track ""`, then read its comments with + `gh issue view "" --comments`, since `resume` prints only the body and a parked lane's state + is in its parking comment. A lane handed back by an attended session has a closed predecessor + holding that comment, so read the predecessor's comments too. Where either names a decision + issue, read the answer recorded there and follow it, since it is what unblocked the lane. + Re-derive live state rather than trusting any of them, per `session-handoff` "Resuming". +2. **Isolate** in a worktree of its own, per `repo-worktree`, on the branch the handoff names or on + `feature/`. +3. **Fix and drive.** Run `local-strict-review` before every push, and drive the pull request with + `drive-pr` to develop, its body carrying `Closes on promotion: #`. Under `main` or + `release`, continue to the promotion pull request, its body carrying a `Fixes` line for every + issue develop fixes, assembled per `backlog-burndown` "Assembling the Promotion Body", and hand + it to `merge-and-release`, merging only under `main` and merging and releasing under `release`. + Every Merge Gate item other than the permission still has to hold. +4. **Wait in the foreground.** Each wait is one bounded command such as `pr_review.py wait`, run in + the worker's own turn. A subagent receives no completion notification, so a wait handed to a + monitor or a background task never wakes it. +5. **Park at the first decision**, per "Parking" below, filing any lesson per step 6 before the + parking comment so the comment can name it. That includes a merge the harness refuses after one + retry, which is parked as ready to merge rather than routed around. +6. **File any lesson for the maintainer.** A lesson a future agent must honor is rule text, which is + the maintainer's to judge and no one is present to judge it, so file it as an issue carrying + `decision`, stating the proposed rule and where it would go, with the choices as its options in + the form `GOVERNANCE.md` "Communicating with the User" sets for any choice put to the maintainer. + The picker never takes a `decision` issue, so the loop cannot write a rule nobody has judged. + Once answered, the label comes off per `GOVERNANCE.md` "Communicating with the User". A declined + rule's issue closes, and an adopted one stays open as ordinary work, which the loop may then + take, since the maintainer has judged it. +7. **Close the lane out on done.** Comment on the handoff what merged, which issues it fixed, and + what it filed along the way, the lesson issue included, then close it. An `auto-*` lane holds one + issue, so its work is complete and it is the closed-out lane `session-handoff` "The Chain" names, + needing no successor. +8. **Save no memory**, since state lives in the chain where any session on any machine reads it. + Reply with one line. + +## Parking + +A worker parks rather than asks, since no one is present to answer. Park in this order, so that an +interruption part way leaves the work findable rather than lost. + +1. **Keep the work.** Commit it and push the branch under the ordinary push rules, and leave the + branch and any pull request open. Where the push cannot run, leave the worktree exactly as it + stands and name it in the comment below. +2. **File the question** as an issue carrying `decision`, per `GOVERNANCE.md` "Communicating with + the User". It states the question, the choices as its options in the form that section sets for + any choice put to the maintainer, what each choice would do to the parked work, the handoff it + belongs to, and every pull request the decision blocks, the open promotion included where it + blocks that, which is what lets a picker find a promotion already waiting on one. Where an open + `decision` issue already asks the same question about the same pull request, name that one + instead of filing another, commenting onto it this handoff, the effect on its work, and every + pull request the decision now blocks. It holds nothing but the question, so it closes once + answered. +3. **Comment the state on the handoff**, filing any lesson first per worker step 6 so the comment + can name it: what is done, the branch and pull request, whether the worktree was left standing, + what remains, and the decision issue it now waits on. This comment is what the next session on + the lane resumes from, so it is complete enough to continue with no other context. +4. **Label the handoff `blocked`**, per `GOVERNANCE.md` "Durable Knowledge and Self-Improvement". + The picker skips it from then on, and the attended session takes it first. + +Each of these is an outward-facing write bound by `GOVERNANCE.md` "Repository Boundaries and Write +Safety": this repository only, every identifier read live in the same run, and no output +suppressed. diff --git a/.github/skills/upstream-contribution-workflow/SKILL.md b/.github/skills/upstream-contribution-workflow/SKILL.md index eec4fcf..6545861 100644 --- a/.github/skills/upstream-contribution-workflow/SKILL.md +++ b/.github/skills/upstream-contribution-workflow/SKILL.md @@ -19,7 +19,7 @@ description: >- ## Why this exists -The fleet's own branching model (`operational-vs-release-workflow`) governs repos the maintainer +The fleet's own branching model (`branching-and-release-model`) governs repos the maintainer controls end to end: squash-only feature branches, merge-commit promotions, signed commits under his own identity. None of that applies to someone else's repository. A PR into a third-party project answers to that project's own maintainers, on their own timeline, with their own review diff --git a/.github/skills/workflow-ci-contract/SKILL.md b/.github/skills/workflow-ci-contract/SKILL.md index 9eaf908..c8a5e1a 100644 --- a/.github/skills/workflow-ci-contract/SKILL.md +++ b/.github/skills/workflow-ci-contract/SKILL.md @@ -1,47 +1,50 @@ --- name: workflow-ci-contract description: >- - Governs the WORKFLOW.md CI/CD behavioral contract for every ptr727/ProjectTemplate fleet repo: the D1-D9 guarantees stated as the failure mode each prevents, the seam contract for release assets, the artifact lifecycle, NBGV versioning and classification, validate-at-entry, and the 5A/5B/5C test methodology with its per-type walkthroughs. Use this whenever writing or editing anything under .github/workflows/, adding or dropping a release target, auditing a repo's workflows, or reasoning about why a publish did or did not fire. This is the YAML half of the pipeline, and the operational-vs-release-workflow skill keeps the git half (branching, promotion, publish policy), so branch choice questions go there. Triggers even when the edit looks mechanical, such as bumping an action, renaming a job, or adding one upload step, because SHA pinning, the ruleset-bound aggregator name, smoke gating on uploads, and retention-days are each easy to break in a one-line diff that no smoke build exercises, since workflow-only changes are deliberately not smoke-built. WORKFLOW.md keeps authority, and GOVERNANCE.md wins where the two overlap. + Governs the WORKFLOW.md CI/CD behavioral contract for every ptr727/ProjectTemplate fleet repo: + the D1-D9 guarantees, the output seam by destination (a GitHub release file, a package-registry + push, an image-registry push, or a filesystem on a host the project owns), the artifact + lifecycle, NBGV versioning and classification, validate-at-entry, and the 5A/5B/5C test + methodology. Use this whenever writing or editing anything under .github/workflows/ or a + composite action under .github/actions/, editing version.json, adding or dropping a release + target, auditing a repo's workflows, or tracing which job, input, or condition made a publish + run or skip. This is the YAML half of the pipeline, and `branching-and-release-model` keeps the + git half, which branch a change targets and which events may publish at all. Triggers even when + the edit looks mechanical, such as bumping an action, renaming a job, or adding one upload step, + because SHA pinning, the ruleset-bound aggregator name, smoke gating on uploads, and + retention-days are each easy to break in a one-line diff that no build fails on. WORKFLOW.md + keeps authority, and GOVERNANCE.md's Workflow YAML Conventions and Release Model sections win + where those two overlap. --- # Workflow CI Contract ## Why This Exists -`WORKFLOW.md` in the hub is the largest law doc, a behavioral contract stating required outcomes rather than a required implementation, and it had no skill surface, so agents edited workflow YAML without the contract in view. This skill is the summary plus the binding rules, with the guarantee catalog and the test methodology split into `references/`. `WORKFLOW.md` keeps authority for the contract and methodology, and `GOVERNANCE.md` ("Workflow YAML Conventions", "Release Model") wins where the two overlap. +`WORKFLOW.md` is the fleet's CI/CD behavioral contract. This skill is that contract's surface, so an agent editing workflow YAML has the contract in view. It carries the summary, and `WORKFLOW.md` sections 3, 4, and 5 are each carried whole in `references/` as a generated include. `WORKFLOW.md`'s own canonical-scope note says which of it and `GOVERNANCE.md` is authoritative where the two overlap. ## How the Contract Is Read -- **Outcomes, not bytes.** A workflow is correct when it satisfies the section 4 contract against the expected inputs and outputs, not when it matches a catalog snippet byte for byte. Two repos may implement one guarantee with different YAML. -- **Applicability.** A guarantee governing a construct the repo does not contain is N/A: recorded, excluded from the verdict, never a defect. A source-only pipeline is mostly N/A and that is fine. +- **Outcomes, not bytes.** A workflow is judged against `WORKFLOW.md` section 4's expected inputs and outputs, never against a snippet byte for byte, per `GOVERNANCE.md` "Foundational Principles". +- **Applicability.** A guarantee, or a 5B scenario from `WORKFLOW.md` section 5, governing a construct the repo does not contain is N/A: recorded, excluded from the verdict, never a defect. A source-only pipeline is mostly N/A and that is fine. - **Operational is binary.** Every applicable guarantee holds, or the workflow is not operational. A single applicable input-output mismatch is a defect regardless of how clean the YAML looks. -- **Reached, not carried.** A standard workflow whose job graph is identical across repos of a type is a `workflow_call` task the hub hosts once, and a repo carries only a caller stub pinned to a hub release commit plus a composite-action hook at `.github/actions/` for what is its own. A hub task reaches its own actions and sibling tasks through `$/`, which resolves at that pinned commit. The merge-bot is the first, and `docs/reusable-workflows.md` in the hub carries the model, the hook contract, and the phase each workflow migrates in. Until a workflow's phase ships, its copy is graded as below. -- **Two layers.** Orchestration (the PR entry workflow, publisher, version and release jobs) is generic and standard at the job level. Build leaves (the `build-` tasks) are repo-owned. Inputs like `github`/`dockerhub`/`expect_release_assets` live on the orchestrator, a leaf receives `ref`/`branch`/`smoke` and whatever else its target needs, a derived `push` among them where that leaf pushes, so assert each input in the layer that declares it. A package target declares no push input on either layer, its push living in a separate `publish-` job in the repo's own publisher. What a repo curates is the list of targets, and adding or dropping one edits the whole surface together: the `enable_` input, the `build-` job and its `github-release` and `build-docker` `needs:` entries, the `changes` paths-filter entry and output, the `smoke-build` enable-forward, and a package target's `publish-` job (D6.4). +- **Reached, not carried.** A standard workflow whose job graph is identical across repos of a type is reached as a hub-hosted `workflow_call` task, per `GOVERNANCE.md` "Hub-Hosted Tooling". The repo's own surface is the caller stub, pinned to a hub release commit, and a composite-action hook at `.github/actions/` for what is its own. A hub task reaches its own actions and sibling tasks through `$/`, which resolves at that pinned commit. The merge-bot is the first, and `docs/reusable-workflows.md` in the hub carries the model, the hook contract, and the stage each workflow migrates in. Until a workflow's stage ships, its copy is graded against the same contract. +- **Two layers.** The pipeline splits into an orchestrator layer and a build-leaf layer, defined in `WORKFLOW.md` section 3's `Two Layers: Orchestration vs Build` and carried in `references/architecture.md`, while `WORKFLOW.md` section 1's `Two layers when auditing` maps which layer declares which input. Assert an input a guarantee names in the layer that declares it. -## Style Rules That Break in One-Line Diffs +## Style Rules -- **Pin every action to a commit SHA** with a trailing `# vX.Y.Z` comment, first-party included. The one documented no-pin exception is `dotnet/nbgv@master`. Invent no others. -- **Names carry meaning**: `-task.yml` files and "task" names are reusable (`on: workflow_call`), entry points end in what they do and their names end in "action", every job `name:` ends in "job" and every step in "step". A ruleset-bound required check's job `name:` and the ruleset `context:` are one string renamed together, in the live ruleset and the hub's `repo-config/` payloads in lockstep, or required-check enforcement silently breaks. -- **Concurrency**: top-level workflows use `group: '${{ github.workflow }}-${{ github.ref }}'` with `cancel-in-progress: true`. Two are documented exceptions. The publisher takes a global ref-independent group with `cancel-in-progress: false`, so publishes serialize and never cancel mid-push. The merge-bot takes `cancel-in-progress: false` and keys on the PR number rather than `github.ref`, per D8.1, so each PR queues independently and every event runs to completion. -- **Shells**: every multi-line bash `run:` starts `set -Eeuo pipefail`. Multi-line `if:` uses `>-`, never `|`. -- **Boolean inputs** are declared in both trigger blocks and compared against both forms, `${{ inputs.foo == true || inputs.foo == 'true' }}`, since `workflow_dispatch` delivers strings. -- **Permissions validate before `if:`**, so a callee declares `permissions:` only where every caller grants that scope at startup and otherwise declares none, running under the calling job's grant. A callee's extra scope (`actions: write` for cleanup) is granted by the caller at the one entry point that needs it. -- **Chaining across optional jobs** allowlists `success`/`skipped` explicitly, because `!= 'failure'` lets `cancelled` through. -- **Docker layer cache** targets a registry tag (`buildcache-`), never `type=gha`. -- **Workflow YAML is LF.** Preserve endings on every edit. +`GOVERNANCE.md` "Workflow YAML Conventions" keeps the style rules, and the `comment-and-doc-style` Skill keeps the line-ending policy, reached from `GOVERNANCE.md` "Documentation Style Conventions" under "Line Endings". Read both before editing a workflow or a composite action. -## The Core Behavioral Spine +## The Contract Text -- **PRs validate fast and never publish**: a paths-filter smoke-builds only changed targets, a type-appropriate validation job always runs, and one required aggregator gates the merge, treating skipped smoke as pass and blocking on failure or cancelled. Smoke does a full compile/lint/test but pushes nothing and uploads nothing, every `upload-artifact` gated `!smoke`. -- **A human merge never auto-publishes**: a `plan` job decides once and every job gates on it. Publishes come from a code-affecting bot push to `main`, a manual dispatch of `main` or `develop`, or the main-only weekly Docker schedule. Each run builds the one trigger branch, `main` a clean `X.Y.Z`, anything else a prerelease `X.Y.Z-g`, with NBGV owning the patch from git height. The release tags the built commit's SHA (`GitCommitId`), never a branch name. -- **Validate at entry**: cross-input and input-versus-derived-state invariants are asserted once in a dedicated entry job the downstream jobs `needs:`, failing fast with `::error::` before expensive work. The release gate checks branch-versus-prerelease in both directions, strips `+buildmetadata`, and on smoke skips the check while the job still succeeds. -- **The seam contract**: a target contributes a release file by uploading `release-asset--`, and the release job collects by `pattern:` plus `merge-multiple:`, never `artifact-ids:`, canonical even for a single target. A repo with no file target passes `expect_release_assets: false` at the caller. -- **Artifacts are an intra-run handoff**: consume-then-delete at the point of consumption, gated to the consumer's condition, best-effort, `retention-days: 1` on every upload as the backstop, and never a blanket delete of the run's artifact set, which destroys the diagnostics you need when the run fails. -- **No-op republish**: an unchanged version re-pushes nothing, the release-create step skips when the tag exists, registries dedupe server-side (`--skip-duplicate`, `skip-existing: true`), and Docker alone always re-pushes by design. -- **A build failure blocks every publish target**: `github-release` needs every build, the terminal registry pusher guards with `!failure() && !cancelled()`, and a package target's separate `publish-` job `needs:` the release-task call, so nothing partial ships. - -The full catalog, each guarantee with the failure mode it prevents, is in `references/d-guarantees.md`. Auditing, tracing, and probing a repo's workflows is `references/test-methodology.md`. +`references/architecture.md`, `references/d-guarantees.md`, and `references/test-methodology.md` carry `WORKFLOW.md` sections 3, 4, and 5 whole, each as a generated include, so the pipeline's architecture, a guarantee's exact wording, and the audit-trace-probe procedure are each one read away rather than restated in full here. A defect in an include region is fixed in `WORKFLOW.md` and regenerated, never edited in this skill, per the `skill-lifecycle` Skill. `WORKFLOW.md` keeps sections 1, 2, and 6 itself, the applicability rule, the style-rule pointer, and the per-project-type walkthroughs, which say which constructs each type adds, map each construct to the scenarios it reaches, and carry three rules for reading a row, one of which is about a repository declaring more than one type, so read those there. ## After Any Workflow Edit -Workflow-only changes are not smoke-built, so run actionlint locally before pushing. Run it from the repository being checked, as `python3 /path/to/ProjectTemplate/scripts/docker_lint.py --root "$PWD" --linter actionlint`, using the hub-hosted wrapper documented in `GOVERNANCE.md`'s hub-only "Running the Linters Locally (Known-Working Invocations)" section. actionlint includes `shellcheck` for `run:` blocks, so `--linter actionlint` already covers them. A workflow change is still only fully exercised by CI, since `secrets: inherit`, `permissions:`, and `needs:` wiring resolve only in a real run. +A workflow-only change is not smoke-built, and actionlint still runs on it in CI. `GOVERNANCE.md` "Verification Discipline" requires the repository's whole lint gate before every push, rather than actionlint alone. A workflow change is still only fully exercised by CI, per the same "Verification Discipline" section. + +**actionlint discovers `.github/workflows/` recursively, both extensions, and opens no action file on its own.** It reaches a local action's `action.yml` only through a workflow's `uses: ./`, wherever in the tree that path leads, and reports everything it finds there against the calling workflow: the caller's `with:` block against the action's declared inputs, the caller's `steps..outputs.` against its declared outputs, and the action's own `name`, top-level `description` and `runs.using`. A missing per-input `description` and an unexpected top-level key are the metadata it does not reach that way. Pointing it at an action file directly makes it parse the file as a workflow and report several syntax-check errors, so widening its file list is not available. **Where no workflow in the repository names the action, actionlint reaches it not at all**, which is the ordinary shape for a repository whose hooks are invoked from a hub reusable workflow rather than from a workflow of its own. + +**A schema check covers the action file itself**, `check-jsonschema`'s `vendor.github-actions` builtin, run as `uvx check-jsonschema@latest --builtin-schema vendor.github-actions -- `. That one reads every tracked `action.yml` and `action.yaml` under `.github/actions/`, whether or not a workflow references it, and reaches the structure and the keys rather than the caller's contract. The schema and actionlint do not agree on every key: the schema accepts `runs.using: node16`, which actionlint rejects as an invalid runner, so a green schema run is not a statement about what GitHub currently accepts. An action file outside `.github/actions/` keeps actionlint's caller check and gets no schema check at all. + +**Neither check reads a composite action's `run:` bodies or its `if:` expressions.** Measured on a referenced action carrying both a malformed `if:` and an unterminated shell `if` in a `run:` block: actionlint and the schema check each pass it. A broken expression or shell body in a composite action therefore surfaces when a run executes it. diff --git a/.github/skills/workflow-ci-contract/references/architecture.md b/.github/skills/workflow-ci-contract/references/architecture.md new file mode 100644 index 0000000..b03c335 --- /dev/null +++ b/.github/skills/workflow-ci-contract/references/architecture.md @@ -0,0 +1,112 @@ +# The Pipeline Architecture + +The section below is `WORKFLOW.md` section 3, whole. The D-guarantees it cites by number are `WORKFLOW.md` section 4, carried whole in `d-guarantees.md` beside this file. + +## The Architecture + + + +### Branch Model + +Two workflow models, set per repo by the registry `workflowModel` field. `release` (default) is the feature-branch pipeline `WORKFLOW.md` specifies: + +```mermaid +flowchart LR + feature[feature branch] -->|squash| develop + develop -->|merge commit| main + main -.->|no back-merge| develop +``` + +`operational` repos (live-service config, `workflowModel: operational`) commit directly to `develop` and promote a known-good snapshot to `main` via an occasional PR: + +```mermaid +flowchart LR + edit[direct signed commit] -->|advisory CI| develop + pr[pull request] -->|lint CI, reported not required| develop + develop -->|merge commit, enforced lint CI| main +``` + +The direct commit is an **allowance, not a substitute for review**. The ruleset drops the pull-request *requirement*, which permits a direct push without withdrawing the pull request, so a change worth reviewing still takes one and both paths reach `develop` legally. Which changes those are is stated as a shape rather than a line count in `GOVERNANCE.md` "Operational Repositories", which owns the test and is the one place it is written, since nothing in a ruleset can apply it. What differs is when validation lands. On the direct-commit path the commit is already on the branch, so CI can only be advisory after the fact, and that is the accepted cost of the model. On the pull-request path the change has not landed, so validation is pre-merge and actionable, which is the moment it is worth the most, and the lint workflow's `pull_request` trigger therefore names `develop` alongside `main` (`WORKFLOW.md` section 6). That is what makes **D1.2** hold here, since its input is *any* PR and the operational model is no exception. The check is reported on a `develop` PR rather than required, because a required status check on `develop` binds the direct push too and would dissolve the allowance the model is built on. + +Their CI is lint/validation only (editorconfig/EOL plus domain linters such as Home Assistant or ESPHome config validation or a firmware build, but **no unit tests**), so the D-guarantees in `WORKFLOW.md` section 4 that assume a build/test pipeline are **N/A** exactly as for `source-only` (`WORKFLOW.md` section 6). What binds: the promotion gate, where the `develop -> main` PR must pass the required `Check pull request workflow status job`, and the source-only release on manual dispatch (`releaseTrigger: dispatch-only`; tag + source zip). Branch-model rulesets are specified in `GOVERNANCE.md` "Branching Model" rather than in `WORKFLOW.md`. + +### Two Layers: Orchestration vs Build + +- **Orchestration** is generic and forms the standardization baseline **at the job level**: the single-branch publisher, the `get-version`, `validate-release`, and `github-release` jobs, and the `changes -> smoke-build -> aggregator` shape of the PR workflow. These job *bodies* should not need per-repo edits. +- **Build** is repo-owned in shape: the leaf a target runs. A repo owning its release task hosts its leaves itself. A repo calling the hub-hosted task shapes a leaf through a composite-action hook of its own, which the task runs in place of its hub default where that file exists. For the .NET, NuGet, and PyPI leaves it is `.github/actions//action.yml`, named for the build job (`dotnet-publish`, `build-nuget`, `build-pypi`). For the Docker leaf it is `.github/actions/docker-prepare/action.yml`, which a `docker_matrix` the caller passes bypasses along with the default. A `docker-build-base` hook, which has no default, builds a shared base layer where the caller sets `docker_build_base`. +- **What the repo curates** (by design, not a leak): the *list* of targets. A repo calling the hub-hosted release task reaches it by pin and carries no copy, so adding or dropping a target edits the caller's own surface. That is the `enable_` value its publisher passes, the publisher's `on.push.paths` entries where it carries a `push` trigger, and `expect_release_assets` where the change adds the first file target or drops the last (D4.3). It is also the `changes` paths-filter entry + output + the `smoke-build` enable-forward in the PR workflow, plus the separate `publish-` job for a package target. The release task's `enable_*` inputs, its per-target build jobs, and their `github-release` and `build-docker` `needs:` entries belong to the task, so a target the hub task declares no input for is a change to that task first (`WORKFLOW.md` section 6, the `library` row). A repo owning its release task edits those in its own file, where "verbatim" applies to the `github-release` job and the version/publish-plan logic, except that job's own `needs:` list, and never to the task's job list. + +### The Seam Contract + +A target contributes a file to the GitHub release by uploading a workflow artifact named `release-asset--`. The release job collects **every** matching artifact by **pattern** (`pattern: release-asset--*` + `merge-multiple: true`), never an `artifact-ids:` naming one job's output. Canonical for **every** repo, single-target included. Switching to an `artifact-ids:` handoff forks the release download. + +```mermaid +flowchart LR + dotnet[dotnet-publish] -->|release-asset-BRANCH-dotnet-publish| store[(run artifacts)] + nuget[build-nuget] -->|release-asset-BRANCH-nuget| store + store -->|pattern + merge-multiple| rel["github-release job (D6)"] + nuget -->|nuget-build-BRANCH| pub["publish-TARGET job in the repo's own publisher"] + pypi[build-pypi] -->|pypi-build-BRANCH| pub + pub -->|push| registries[(registries)] + docker[build-docker] -->|push| registries +``` + +The diagram writes `BRANCH` and `TARGET` where the prose writes `` and ``, because a mermaid label is sanitized as HTML at render and an angle-bracket placeholder is dropped as an unknown tag. This reaches node labels as well as edge labels, which is why the Release Model diagram below writes `X.Y.Z-g-sha` rather than bracketing its own placeholder. + +### Reusable-Task Parameter Contract + +Every leaf and the release task take `ref`, `branch` (the **logical** branch that drives config/tags/prerelease), and where relevant `smoke`. Branch-derived config keys off `inputs.branch` (the logical branch the caller passes). Artifact names carry the branch. + +### Versioning + +NBGV versions the branch being published. Each run builds a single branch (the trigger ref), so `GITHUB_REF` already names it and NBGV classifies it directly, and no `IGNORE_GITHUB_REF` override is required. The default branch is the public-release ref, so it builds clean `X.Y.Z`. Every other branch builds a prerelease `X.Y.Z-g`. `version.json`'s `version` is the major.minor floor. NBGV appends the git height as the patch. **NBGV and `version.json` are retained even by a repo with no compiled code**, since they are the source of the release tag (`SemVer2`) and `target_commitish` (`GitCommitId`) and the prerelease classification. The .NET SDK is pulled in only as the versioning toolchain. A package build derives its registry version from the same NBGV outputs: the PyPI version is the `X.Y.Z` core of `SemVer2`, any prerelease or build segment dropped, with a PEP 440 `.dev0` appended on the `develop` branch. A wrapper repo may drive its build/image version from an external committed `name -> version` state file while NBGV still tags the release. + +### Validate-at-Entry + +When a workflow's inputs carry a cross-input or input-versus-derived-state invariant, assert it **once** in a dedicated entry job/step the downstream jobs `needs:`, failing fast with `::error::` before any build or publish. + +### Resource Lifecycle + +Workflow artifacts are an **intra-run handoff** only. Durable copies live on the release/registry. The rule: a transfer artifact handed **between jobs** is deleted by exact name/pattern **at its point of consumption**, the delete is **gated to the half of the consumption whose failure would leave it not yet redundant** (D5.2 names the two halves), and it is **best-effort**. **Every** `upload-artifact` sets `retention-days: 1` as the universal failure-path backstop, so no terminal blanket-delete job is needed. The run is **never** blanket-deleted (`.artifacts[].id`). See D5. + +### Fast PR Feedback + +PRs validate fast and never publish: a paths-filter smoke-builds only changed targets. A validation job always runs. Smoke builds compile/lint/test but upload nothing and push nothing. One required aggregator gates the merge. See D1. + +```mermaid +flowchart TD + pr[pull request] --> ch[changes paths-filter] + ch -->|target changed| sb[smoke-build changed targets] + ch -->|workflow-only or docs| skip[smoke-build skipped] + val[validation job] --> agg["Check pull request workflow status job (D1)"] + sb --> agg + skip --> agg + agg -->|success| ok[merge allowed] +``` + +### Release Model + +Each publish builds a **single branch**, the trigger ref (`main` a release, `develop` a prerelease), so there is no branch matrix and `github.ref` always names the built branch. A **human merge never auto-publishes**: a `plan` job (`publish-plan-task.yml`) decides once, and a job downstream of it either gates on its outputs or inherits the skip through `needs:`. A run publishes on a **code-affecting bot push to `main`** (the App merges every Dependabot/codegen PR, so `github.actor` gates it, and the publisher's own `on.push.paths` list also drops a non-substantive change like an Actions bump), a **manual dispatch** of `main`/`develop`, or a **main-only weekly schedule** (Docker, to refresh the base image). The `push` is main-only, so a develop bot merge publishes nothing (its prerelease comes via dispatch). A **source-only** repo publishes on **dispatch only**. Every release is a tag on the built commit plus a source archive, README, and LICENSE. Targets amend it with `release-asset-*` files, and a registry push contributes none, made by the Docker leaf for an image and by the separate `publish-` job for a package. An unchanged version cuts no second release on a schedule or push trigger, while a dispatch refreshes it (D4.4). Docker re-pushes by design. + +```mermaid +flowchart TD + trig[main-only schedule / dispatch / paths-filtered push] --> one[build the one trigger branch] + one -->|main| vmain["version X.Y.Z stable (D3)"] + one -->|develop| vdev["version X.Y.Z-g-sha prerelease (D3)"] + vmain --> relm["github-release + registries: latest (D4)"] + vdev --> reld["github-release + registries: prerelease (D4)"] +``` + +### Output Seam by Destination + +Pick each output's path by **where the artifact goes**: + +- **File on the GitHub release** (zip, binary, packaged library): one leaf per output uploading `release-asset--`. The repo keeps `expect_release_assets: true` (its default). +- **Package-registry push** (NuGet, PyPI): the leaf builds and uploads a build artifact (`nuget-build-` / `pypi-build-`), and a separate `publish-` job in the **publishing repository's own** publisher consumes it and pushes. Both registries publish through OIDC Trusted Publishing, never a stored API key, and two things put that push outside the leaf. Trusted publishing validates the OIDC token's `job_workflow_ref` claim, which names the workflow the job actually ran from, so a push made from a reusable workflow a *different* repository hosts is rejected at the token exchange, NuGet.org answering `HTTP 401` with `does not start with //.github/workflows/`. That alone rules out a leaf another repository hosts. A leaf this repository hosts clears the claim, and the split still applies to it, because a called job declaring no `permissions:` runs under the calling job's whole grant, so a push anywhere inside the release task would put `id-token: write` on every job in it rather than at the one entry point D7.2 requires. The registered trusted-publishing policy therefore names the publisher, `publish-release.yml`. PyPI additionally gates its publish job behind an environment. NuGet.org binds its policy to the workflow file rather than to an environment and needs none. NuGet's leaf also uploads a `release-asset-*` carrying the package, and PyPI contributes none. +- **Image-registry push** (Docker): the leaf pushes the default branch multi-arch (amd64+arm64) and any other branch `amd64`-only (arm64 emulation is reserved for the released image), and contributes no `release-asset-*`. +- **Filesystem on a host the project owns** (a static site, a config tree): the leaf builds the tree, ships it to the host, and contributes no `release-asset-*`. The transport is the repo's own. What the contract fixes is that the deploy is a **separate `workflow_dispatch`** from the release, so a redeploy of an unchanged commit mints no tag and a host rebuild, a rollback, or proving a branch on a non-production environment costs nothing; that its credentials come from a **per-environment GitHub Environment** rather than the repository secret store; and that the deploy ends by asserting **what the host serves** rather than the transport's exit status (D4.6). Retention at the destination is bounded by a declared count with one side recorded as owning the prune, which is the deploy where its credential can observe the destination and the host where that credential is deliberately write-only (D5.6). +- **No file target via the release task** (Docker-only, PyPI-only, source-only): the release is tag + source zip + README + LICENSE. The caller **MUST pass `expect_release_assets: false`** to the release task. A publisher with file targets retains the default `true`. This setting is caller-specific. The default `true` fails on `fail_on_unmatched_files` when no assets exist. A **source-only** repo also passes every `enable_*` input as false because it has no build leaf (see `WORKFLOW.md` section 6). + +`WORKFLOW.md` section 3 keeps the architecture, and the `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/references/architecture.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries this section whole as a generated include. + + diff --git a/.github/skills/workflow-ci-contract/references/d-guarantees.md b/.github/skills/workflow-ci-contract/references/d-guarantees.md index b32348b..1a39662 100644 --- a/.github/skills/workflow-ci-contract/references/d-guarantees.md +++ b/.github/skills/workflow-ci-contract/references/d-guarantees.md @@ -1,70 +1,87 @@ -# The D-Guarantees, Condensed +# The D-Guarantees -Each guarantee is a MUST from `WORKFLOW.md` section 4, stated as input to output plus the failure mode it prevents. This is the condensed catalog for working from, and `WORKFLOW.md` keeps authority, so read the section there when a guarantee's exact wording decides a verdict. +The section below is `WORKFLOW.md` section 4, whole. Which of its items bind a given repository is `WORKFLOW.md` section 1's applicability rule. The architecture these items govern is `WORKFLOW.md` section 3 and the methodology that checks them is `WORKFLOW.md` section 5, carried whole in `architecture.md` and `test-methodology.md` beside this file. -## D1: PR Fast-Feedback (Smoke) +## The Behavioral Contract -- **D1.1** Only changed targets build: each target has a paths-filter entry, unchanged targets skip. Prevents a changed target slipping through unbuilt. -- **D1.2** A validation job always runs on any PR, and a non-.NET repo replaces it (never deletes it), re-pointing every `needs:` on it, the aggregator and `smoke-build` both. Prevents a PR merging with no validation, or a dangling `needs:` failing the workflow to load. -- **D1.3** Smoke never publishes and never uploads: full compile/lint/test, no pushes, every `upload-artifact` gated `!smoke`. Prevents a PR publishing and orphaned artifacts. -- **D1.4** Workflow-file changes are not smoke-built (the filter excludes `.github/workflows/**`), actionlint still validates them. -- **D1.5** One required aggregator gates merge: `needs:` the changes and validation jobs, passes on skipped smoke, blocks on failure or cancelled, and its name is ruleset-bound (job `name:` equals ruleset `context:`, renamed together). -- **D1.6** Coverage reports to Codecov for C# and Python repos with tests, best-effort so an outage never reds the gate, with a `codecov.yml` setting statuses informational and `.gitignore` excluding coverage output. + -## D2: Validation at Entry +The required behaviors, organized by domain. Each is a **MUST**, and its `Output:` states what a conforming pipeline is required to hold. An `Output:` may be a behavior a run exhibits, or a property of the committed source such as a SHA-pinned action or a `retention-days:` setting, and the two kinds bind on the same terms. An item may also carry an `Input:`, where the guarantee turns on a particular trigger or state rather than on every run, a *Prevents:*, where the failure it rules out is not evident from the `Output:` itself, and an *Implication:* or a *Note:*, for a consequence and for a caveat. Applicability is `WORKFLOW.md` section 1's rule rather than a label's, so an item scoped to a repository shape says so in its own prose. A workflow that violates any *applicable* guarantee is **not operational**. -- **D2.1** A dedicated entry job asserts each cross-input invariant before expensive work, downstream jobs `needs:` it. -- **D2.2** The release gate fails loud when the default branch carries a prerelease suffix or a non-default branch carries none, strips `+buildmetadata` first, and on smoke skips the check while the job still succeeds (a job-level `if:` would skip dependents with it). -- **D2.3** A dispatch publish from any ref other than `main` or `develop` fails fast. -- **D2.4** Mutually-exclusive or must-pair inputs are validated, a half-filled combination fails fast. +### D1 - PR Fast-Feedback (Smoke) -## D3: Versioning and Classification +- **D1.1 Only changed targets build.** Input: a PR touching some targets. Output: the paths-filter marks exactly those targets and only their smoke builds run. Unchanged targets skip. A repo's own targets MUST each have a filter entry (so a touched target is never silently skipped), and that entry lists paths rather than negating them, so a change matching no entry marks nothing and every smoke build skips. *Prevents: rebuilding everything, and a changed target slipping through unbuilt.* +- **D1.2 A validation job always runs.** Input: any PR. Output: a validation job runs unconditionally and the aggregator `needs:` it. That job is the caller's own job reaching the reusable validator, named `validate` in every shipped stub, and that name is what the aggregator's `needs:` carries. The validator's internal jobs (`lint`, `unit-test` and `validate` in the hub's `validate-task.yml`) are not addressable from a caller, so a `validate` in a caller's `needs:` list always names the caller's own job rather than the validator's internal one of the same name. The validator detects the tree rather than the repo's language, running the doc and repo gates everywhere, the `dotnet test` path only where a `*Tests*.csproj` project exists, and the `pytest` path only where a root `tests/` directory sits beside a root `pyproject.toml` and a root `uv.lock` or `requirements*.txt`, so a non-.NET repo calls the same one rather than replacing it. A repo whose tests take another shape can run them from its own `.github/actions/validate/action.yml` hook, which the validator runs where that file exists. That hook receives no secret, so a repo whose other-shape tests owe D1.6's coverage upload is one whose validation the validator cannot express. A repo whose validation it cannot express **replaces** the call (not deletes it) with its own validator and re-points the aggregator's `needs:` to the replacement. `smoke-build` `needs:` the `changes` job rather than the validation job, so no second `needs:` moves with it. *Prevents: a PR merging with no validation, or a dangling `needs:` that stops the whole workflow from loading.* +- **D1.3 Smoke never publishes and never uploads.** Input: `smoke: true`. Output: full compile/lint/test, but no registry/image push, no release, and **no** artifact uploads (every `upload-artifact`, including any aggregation job, is gated on smoke being false, written `!inputs.smoke` at the workflow layer and `inputs.smoke != 'true'` in a composite action, whose inputs are strings). *Prevents: a PR publishing, and orphaned artifacts churning the storage quota.* +- **D1.4 Workflow-file changes are not smoke-built.** Input: a PR changing only `.github/workflows/**`. Output: the paths-filter marks no target, so smoke-build skips. An inclusion list satisfying D1.1 reaches this by leaving workflow paths out of every target's entry. *Implication: a workflow-only change is not smoke-built, but actionlint still validates it in CI.* +- **D1.5 One required aggregator gates merge.** Input: any PR. Output: a single aggregator job must **succeed**, run under `if: always()` so a failed or skipped dependency cannot skip the gate itself, `needs:` the validation job, and the `changes` and `smoke-build` jobs too wherever the repo has a smoke build, treat a **skipped** smoke build as pass, and **block** on `failure`/`cancelled`. Its name is ruleset-bound: the job `name:` and the ruleset `context:` are the same string and MUST be renamed together, never independently. *Prevents: a paths-filter error letting a target-changing PR merge unbuilt.* +- **D1.6 Coverage is reported to Codecov (C# and Python).** Input: a C# or Python repo that has tests for that type. Output: the validation job runs those tests under coverage collection (`dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage`, leaving `--coverage-output` unset so each test project writes its own report rather than overwriting a shared one, or `pytest --cov-report=xml` over a repo whose own `pyproject.toml` selects what to measure) and a `codecov/codecov-action` step uploads the report, **best-effort** (`continue-on-error` and/or `fail_ci_if_error: false`, so a Codecov outage or an absent token never reds the gate). The Python leg **fails its test step when no report was written**, since `--cov-report=xml` alone selects nothing to measure. The C# leg renames each report to `coverage-.cobertura.xml` before the upload step reads the directory, `codecov-cli`'s own finder not matching the default name, and a repo owning its validator rather than calling the hub's owes that rename itself. `CODECOV_TOKEN` lives in the repo's **actions** and **dependabot** secret stores, the second because a run triggered by a Dependabot pull request reads the Dependabot store and the upload would otherwise skip silently on every bot pull request. A caller reaching the reusable validator across repositories names the secret it passes (`secrets:` with `CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}`), on its pull request path and its publisher path alike, because `secrets: inherit` is documented for a caller in the same organization or enterprise, which a personal account is not. A call by local path stays inside one repository, where the caller's own store is the one the callee reads, so `secrets: inherit` is available there instead of naming each secret. The repo ships a **`codecov.yml`** setting the project and patch statuses to **`informational: true`** so a coverage delta never gates a pull request (a repo whose quality bar requires a threshold may turn that off), and excluding intentionally-untested, non-shipped code (an example or benchmark project) from the denominator via `ignore`. Coverage output is a build artifact, so `.gitignore` excludes it. The C# invocation runs under **Microsoft.Testing.Platform**, and the runner declaration, package references, and version floor an MTP-based test project needs are `CODESTYLE.md`'s .NET side. The Python invocation needs **`pytest-cov`** and a coverage selector, which are `CODESTYLE.md`'s Python side. N/A for a repo carrying no tests for that type, and for a `lint-only` profile for it (per the hub's `registry/repos.json`). *Prevents: coverage silently going unreported, and a coverage regression blocking an unrelated pull request.* -- **D3.1** One branch per run: `github.ref` names the built branch, NBGV classifies it directly, no `IGNORE_GITHUB_REF`. -- **D3.2** Default branch yields `X.Y.Z`, every other branch `X.Y.Z-g`, and the default-branch literal in the gate, the `prerelease` expression, and `version.json`'s `publicReleaseRefSpec` all name the repo's real default branch. -- **D3.3** `version.json` sets the major.minor floor, NBGV appends git height as the patch, and both are retained even by a no-compiler repo, since they own the tag. -- **D3.4** Registry versions follow the classification per registry: NuGet.org derives prerelease from the SemVer2 suffix, PyPI builds from `AssemblyFileVersion` with `.dev0` appended on `develop` only, and the develop build stays `--pre`-selectable above the released version. -- **D3.5** A wrapper repo drives its image version from a committed `name -> version` state file, and the leaf must actually read it, since a leaf still tagging off NBGV means the wrapper is not pinned to upstream. +### D2 - Input/State Validation at Entry -## D4: Release and Publish +- **D2.1 Validate before expensive work.** Output: a dedicated entry job/step asserts each cross-input/derived-state invariant and fails fast before builds. Downstream jobs `needs:` it. +- **D2.2 Release branch matches version classification.** Input: a real (non-smoke) release build. Output: the gate fails loudly if the default branch carries a prerelease suffix **or** a non-default branch carries none. It strips `+buildmetadata` before testing for the prerelease `-` (only a core/prerelease `-` counts), and on a smoke build the **check exits early while the job still reports success** (a detached PR head always versions as prerelease). Read that as the validation being skipped rather than the job, because a job-level `if:` would skip the job itself, and a dependent whose `if:` carries no status-check function, an absent `if:` included, gets the implicit `success()` and skips with it. `validate-release` has such dependents, so a job-level skip there would skip their smoke builds with it. *Prevents: a non-default leg published as stable, a build-metadata false-positive, and the gate blocking every default-base promotion PR.* +- **D2.3 Publish only from main or develop.** Input: a dispatch publish. Output: a dispatch from any ref other than `main` or `develop` fails fast. *Prevents: cutting a release from an unintended branch.* +- **D2.4 Mutually-exclusive / paired inputs are validated.** Input: a workflow with either/or or must-pair inputs (e.g. the docker-readme task's `repositories` XOR `manifest`+`manifest-jq`). Output: a half-filled or conflicting combination fails fast. *Prevents: a silent fall-through.* +- **D2.5 The release gate refuses a branch input git would not accept as a name.** Input: a release task taking a `branch` input, on any run. Output: the gate runs `git check-ref-format --branch` over that value and fails when git rejects it, ahead of D2.2's smoke exit, with an error echoing no part of the value. *Prevents: a `::` or `##[` in a caller's value forming a workflow command wherever a later step prints the branch, on a smoke run as much as on a publish.* -- **D4.1** Gated single-branch publish: a human merge never auto-publishes, the `plan` job decides once, publishes come from a code-affecting bot push to `main`, a dispatch of `main`/`develop`, or the main-only weekly Docker schedule. -- **D4.2** `target_commitish` is the built commit's SHA (NBGV `GitCommitId`), never a branch name and never `github.sha`. -- **D4.3** Every release is a tag plus source zip, README, and LICENSE, file targets attach `release-asset-*`, and a no-file-target caller passes `expect_release_assets: false` or the release-create step fails on unmatched files. -- **D4.4** No-op republish: an unchanged version re-pushes nothing, the release-create skips when the tag exists (refreshed only on `workflow_dispatch`), registries dedupe server-side, and Docker always re-pushes by design. -- **D4.5** A failed build blocks every publish target: `github-release` needs every build, the terminal registry pusher guards `!failure() && !cancelled()`, and a package target's separate `publish-` job `needs:` the release-task call, so nothing partial ships. -- **D4.6** A deploy check asserts which release and which environment answer, waiting for convergence to a bounded timeout, with an unreachable host reported distinctly from an HTTP status. +### D3 - Versioning and Classification -## D5: Resource Cleanup +- **D3.1 One branch per run.** Input: a publish triggered on `main` or `develop`. Output: the run builds and versions that one branch, and `github.ref` names it, so NBGV classifies it directly (no `IGNORE_GITHUB_REF`). *Prevents: a cross-branch ref mismatch misclassifying the version.* +- **D3.2 Default = public, others = prerelease.** Output: default branch -> `X.Y.Z`, and any other -> `X.Y.Z-g`. Every literal that keys default-branch behavior, `version.json`'s `publicReleaseRefSpec` among them, MUST name the repo's real default branch. The hub-hosted tasks and their default leaves write it as `main`, so a repo calling them has `main` as its default branch. +- **D3.3 Version floor + git height.** Output: `version.json` sets the major.minor floor. NBGV appends the git height as the patch, and the maintainer moves the version by bumping the floor. NBGV and `version.json` are retained even by a no-compiler repo (they own the tag). +- **D3.4 Registry versions follow the classification, per registry.** Output: NuGet default = stable, others = prerelease (derived by NuGet.org from the SemVer2 `-g` suffix on `PackageVersion`, not a flag the workflow sets). PyPI builds from the `X.Y.Z` core of `SemVer2`, any prerelease or build segment dropped, and appends `.dev0` on the `develop` branch only (a two-branch literal, not a generic N-branch rule). The develop build stays `pip install --pre`-selectable, `.dev0` being a PEP 440 development release. *Note: at a shared `version.json` floor, `M.N.P.dev0` sorts below `M.N.P` at equal height, so a develop build does not always lead the stable release. A develop-only floor bump leads on the floor whatever the heights.* *Prevents: a non-default leg published as a release.* +- **D3.5 Wrapper repos may use an external version.** Output: a repo wrapping an upstream release drives its build/image version from a committed `name -> version` state file, while NBGV still tags the release. *Note: the tracker (the writer) ships without consumer wiring, so a wrapper must wire the leaf to read the state file (e.g. `jq` into the image tag) instead of `SemVer2`. If the leaf still tags off NBGV, the wrapper is not actually pinned to upstream.* -- **D5.1** A cross-job transfer artifact is deleted at its point of consumption. An in-run intermediate may rely on the retention backstop. -- **D5.2** The delete runs under the same condition as its consumer, so a no-op re-run skips the release-asset delete while the `nuget-build-*` and `pypi-build-*` deletes still run. -- **D5.3** Cleanup is best-effort (`continue-on-error`, tolerate a failed listing, delete all matching ids). -- **D5.4** Every `upload-artifact` sets `retention-days: 1`. -- **D5.5** Never blanket-delete the run's artifacts, which destroys diagnostics and auto-emitted build records. -- **D5.6** A durable deploy destination's retention is bounded by a declared count with one side recorded as owning the prune: the deploy where its credential can observe the destination, the host where the credential is deliberately write-only. +### D4 - Release / Publish -## D6: Seam Conformance +- **D4.1 Gated single-branch publish.** Output: PRs smoke-test and publish nothing. A **human merge never auto-publishes**. A `plan` job (`publish-plan-task.yml`) decides once. A job downstream of it either gates on the plan's outputs or inherits the skip through `needs:`. A gate on an output compares it against `'true'` rather than testing it bare, since a job output is always a string. A publish runs on a **code-affecting bot push to `main`** (gated to the codegen App / Dependabot `github.actor`, with an Actions-only bump matching no release path and publishing nothing), a **dispatch** of `main`/`develop`, or a **main-only weekly schedule** (Docker). A source-only repo publishes on dispatch only. Each run builds one branch. +- **D4.2 Tag the built commit.** Output: the release `target_commitish` is the built commit's SHA (NBGV's `GitCommitId`), never a branch name or a separately re-resolved ref. *Prevents: the tag landing on the default branch instead of the built tree.* +- **D4.3 Release contents.** Output: every release contains a tag on the built commit plus the auto source zip, README, and LICENSE. File targets attach `release-asset-*`. The `prerelease` value equals `branch != default`. A no-file-target caller sets `expect_release_assets: false` to reach the no-asset shape. This applies to Docker-only, PyPI-only, and source-only repos. A NuGet target is not among them, since its leaf uploads a `release-asset-*` carrying the package, so a NuGet-only caller keeps the default `true`. The setting relaxes `fail_on_unmatched_files` and skips the asset download. The release-create step fails when no assets exist and the setting retains its default `true`. A source-only caller also sets every `enable_*` input false. +- **D4.4 No-op republish.** Input: a re-run whose version is unchanged, on a schedule or push trigger. Output: no second release is cut, because the release-create step is skipped when `gh release view` finds a release for the tag, and the paired asset-delete is skipped with it. A **dispatch** re-run refreshes the release instead and runs that delete with it, which is why a dispatch-only publisher records this item's skip leg as unreachable rather than failed. Package-registry pushes are no-ops. The NuGet/PyPI publish steps are **not** statically gated on existence. They run and the **server** dedupes (`dotnet nuget push --skip-duplicate` turns a 409 into success, and PyPI does the same under `skip-existing: true`). **Docker always re-pushes** the image (base-image refresh), independently of the release-create skip, within the same run. *Prevents: duplicate releases and wasted pushes.* +- **D4.5 A build failure blocks every publish target, within the Docker limit below.** Input: a real publish where one enabled build fails. Output: nothing publishes. The limit: where a Docker target builds a shared base or more than one image, its task pushes the base and each image leg as it builds, so a leg failing after the base or a sibling leg pushed leaves those images in the registry with no release. `github-release` needs every build and carries the same `!failure() && !cancelled()` guard the terminal registry pusher (Docker) does, since the implicit `success()` would otherwise skip both on every run that disables a target rather than only on a failed one. A failed build therefore skips the release (no tag, no release), and Docker, which needs every other build, skips with it (no image push), while a **disabled** target, skipped rather than failed, still lets docker push. A package target's separate publish job needs its own gate for the same reason, since it sits outside the `github-release` and Docker `needs:` chains: it `needs:` the release-task call, so a failed build skips it with the rest. The push itself is what no gate can cover, because it runs after the whole release task and therefore after `github-release`, for the trusted-publishing reason `WORKFLOW.md` section 3's "Output Seam by Destination" package-registry bullet gives, so a rejected token exchange, a registry outage, or a trusted-publishing policy naming the wrong workflow file leaves a published release and tag for a version that never reached the registry. The recovery is a re-dispatch or a full re-run rather than a cleanup. **A full re-run is always available inside its window and is the only route once the branch tip has moved.** The `Re-run failed jobs` shortcut is not a third route here, D5.2's delete having already removed the artifact it would download. `GOVERNANCE.md` "Release Model", and the skill it routes to, carry the mechanics of each route, how to choose, and the window. *Prevents: a partial publish, e.g. a Docker image pushed while .NET publish failed and no release was cut.* +- **D4.6 Deploy verification names the release.** Input: a deploy to a filesystem on a host the project owns that completes without error. Output: a check against the running host asserts **which release is answering**, not merely that it answers. The artifact stamps its own version into the configuration it ships, and the check compares that against the version just installed, **waiting for convergence to a bounded timeout** rather than sampling once, because content goes live the instant a pointer moves while server rules wait on an asynchronous reload. The same check asserts **which environment** answered, since several environments serve a byte-identical artifact and a proxy rule aimed at the wrong one answers healthily under the right hostname. An unreachable host is reported distinctly from an HTTP status. *Prevents: a green deploy over a host still serving the previous release's configuration, a URL contract checked against the wrong environment, and a dead config watcher read as a routing fault.* -- **D6.1** The release job downloads by `pattern:`/`merge-multiple:`, never `artifact-ids:`, canonical for single-target repos too. -- **D6.2** Branch-derived config reads `inputs.branch`, never `github.ref_name`. -- **D6.3** Artifact names are branch-suffixed. -- **D6.4** A target add or drop updates the whole surface together: `enable_` input, `build-` job, its `github-release` and `build-docker` `needs:` entries, paths-filter entry and output, the `smoke-build` enable-forward, and a package target's separate `publish-` job. +### D5 - Resource Cleanup -## D7: Concurrency, Permissions, Safety +- **D5.1 Delete at the point of consumption.** Output: the job that downloads a **cross-job** transfer artifact deletes it (by exact name/pattern) right after consuming it. *Prevents: transfer artifacts accumulating against the storage quota.* +- **D5.2 Gate the delete by the kind of consumer it follows.** Output: where the consumer is a conditional step (the GitHub release create), the delete carries that same condition, narrowed by `inputs.expect_release_assets`. Where the consumer is a step that always attempts once its job runs (a package publish job's push), the delete is gated on the **download** having succeeded rather than on the push, as `if: ${{ !cancelled() && steps..outcome == 'success' }}`. A step whose `if:` carries no status-check function, an absent `if:` included, inherits `success()` instead, which skips it on exactly the failed push where the artifact is already downloaded and the release is already cut. So on a no-op re-run that is not a dispatch the `release-asset-*` delete is **skipped** with the release create it follows, while the `nuget-build-*` and `pypi-build-*` deletes still **run**. A dispatch re-run refreshes the release instead (D4.4), so its asset delete runs with it. Deleting the `nuget-build-*` or `pypi-build-*` artifact on the failed-push path costs the run its **Re-run failed jobs** route, since the re-run's download then finds nothing, so the recovery for a failed push is one of the two routes D4.5 names, and `GOVERNANCE.md` "Release Model", with the skill it routes to, sets out how far that cost actually reaches. *Prevents: deleting freshly built assets on a no-op re-run, and stranding a downloaded artifact when the push it fed fails.* +- **D5.3 Best-effort.** Output: cleanup is `continue-on-error`, tolerates a failed listing, and deletes **all** matching ids. *Prevents: a cleanup hiccup reddening a job whose publish succeeded.* +- **D5.4 Retention backstop.** Output: **every** `upload-artifact` sets `retention-days: 1`. +- **D5.5 Never blanket-delete.** Output: cleanup MUST NOT enumerate and delete the run's whole artifact set. *Prevents: destroying diagnostic/log artifacts and auto-emitted build-records.* +- **D5.6 A durable destination's retention is bounded and owned.** Input: a deploy that installs a release beside the retained ones on a host the project owns. Output: retention is bounded by a **declared count**, and the side owning the prune is **written down**. Where the deploy credential can observe the destination, the deploy asserts the count converged and fails when it does not. Where the credential is deliberately write-only, so it can neither delete nor read back, the prune belongs to the **host** and that ownership is recorded there: widening the credential to reach the destination would trade a real confinement boundary for a check, which is the wrong trade. The release the live pointer resolves to is never a prune candidate, whatever the sort order says. A prune that runs against a local scratch tree, or that is best-effort, or that no side is recorded as owning, satisfies none of this. Unlike D5.1 through D5.4, this destination is durable rather than a run-scoped artifact, so no retention backstop expires it. *Prevents: a destination growing without bound until the disk fills, which surfaces as a site outage rather than as a failed deploy; and the split-ownership version of the same, where each side assumes the other prunes.* -- **D7.1** The publisher serializes: global ref-independent concurrency group, `cancel-in-progress: false`. -- **D7.2** A reusable job declares `permissions:` only where every caller grants that scope at startup (the block is validated before `if:`), and otherwise declares none and runs under the calling job's grant, a callee's extra scope granted by the caller at the one entry point needing it. -- **D7.3** Boolean inputs are declared in both trigger blocks and compared against both forms. -- **D7.4** Optional-dependency chaining allowlists `success`/`skipped` explicitly. +### D6 - Seam / Architecture Conformance -## D8: Bots and Automation +- **D6.1 Pattern handoff.** Output: the release job downloads by `pattern:`/`merge-multiple:`, not `artifact-ids:`. **File** targets upload `release-asset--`, and a target contributing no file to the release (Docker, PyPI) uploads no `release-asset-*` of its own, per D4.3, whatever other transfer artifact it uploads. The `pattern:` download is canonical for a single-target repo too, which does not special-case itself to `artifact-ids:`. +- **D6.2 Branch drives config.** Output: a called workflow's branch-derived config reads `inputs.branch`, never `github.ref_name`. +- **D6.3 Branch-named artifacts.** Output: every artifact name carries the branch, so a branch's artifacts do not collide with another branch's. +- **D6.4 Target add/drop is consistent.** Output: adding or dropping a target updates **all** of: the `enable_` value the publisher passes to the release task, the publisher's `on.push.paths` entries where it carries a `push` trigger, the `changes` paths-filter entry + output, the `smoke-build` enable-forward, and `expect_release_assets` where the change adds the first file target or drops the last (D4.3), plus, for a package target, the separate `publish-` job. A repo owning its release task also updates the target's build job there, `build-` or `dotnet-publish` for the .NET target, and its `github-release` and `build-docker` `needs:` entries. Everything in the `github-release` job **except its `needs:` list** stays verbatim, and so does the version and publish-plan logic. "Verbatim" never reaches the surfaces this item requires editing, that `needs:` list, the release task's job list, and the paths-filter among them. *Prevents: a partial subset that startup-fails on a missing leaf or never smoke-builds a target.* -- **D8.1** The merge-bot enables auto-merge on `opened`/`reopened` for every Dependabot tier, dispatches squash or merge by base ref, disables on a maintainer-pushed `synchronize`, and keys concurrency on the PR number, not `github.ref`. -- **D8.2** Codegen runs a deterministic matrix over both branches, Dependabot targets both branches. -- **D8.3** The upstream tracker writes a committed `name -> version` state file via a rolling per-branch bump PR the merge-bot auto-merges, and its branch prefix must match the merge-bot's head-ref pairs or auto-merge silently never fires. -- **D8.4** An identity allowlist used as a gate emits a `::warning::` on the non-matching branch rather than falling through silently, since a renamed App slug otherwise turns the gate off invisibly. +### D7 - Concurrency, Permissions, Safety -## D9: Style and Static +- **D7.1 Publisher serializes.** Output: the publisher uses a **global, ref-independent** concurrency group with `cancel-in-progress: false`. *Prevents: a schedule and a dispatch double-pushing, or a cancelled publish leaving a partial release.* +- **D7.2 A called job's permissions block is validated before its `if:`.** Output: a reusable job declares `permissions:` only where **every** caller grants that scope at startup, and otherwise declares none and runs under whatever the calling job granted. A callee's extra scope (e.g. `actions: write` for cleanup, or `id-token: write` for OIDC) is granted by the caller and appears at exactly the one entry point that needs it. *Prevents: a `startup_failure` on every caller that does not grant a scope only one target needs, including a smoke build under a read-only pull request token.* +- **D7.3 A `github.event.inputs` boolean is compared as a string.** Output: a boolean read through `github.event.inputs.` is compared against `'true'`, since that context delivers every input as a string whatever the input's declared type. Comparing it 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. == true` is false even on the run where the input arrived as `true`. The `inputs` context preserves the declared boolean on the `workflow_call` and `workflow_dispatch` paths alike, so an `inputs.` read is used directly, and a both-forms comparison there is redundant rather than wrong, which is why the hub's Docker build task comparing its `build-base` input in both forms is not a finding. A workflow carrying both trigger blocks declares each boolean input in both, since one declaration does not propagate to the other, while a boolean that only ever arrives by `workflow_call` is declared in that block alone. `smoke` is such a boolean, every hub task declaring it being `workflow_call`-only, which is why D1.3 writes the workflow-layer gate `!inputs.smoke` against the real boolean and the composite-action gate `inputs.smoke != 'true'` against a string, a composite action's inputs being strings whatever their caller passed. A job or step **output** is always a string as well and takes the same `== 'true'` rather than a bare truthiness test, since the string `'false'` is truthy. *Prevents: a dispatch-path string read as truthy, and a comparison against the boolean `true`, which can never fire, standing in for the one that can.* +- **D7.4 Optional-dependency chaining.** Output: a cross-job condition chaining across an **optional** dependency allowlists `success`/`skipped` explicitly, paired with a status-check function such as `always()` or `!failure() && !cancelled()`. Without one the implicit `success()` applies and is false the moment any `needs:` job skipped, which is the case the allowlist exists to admit. *Prevents: a condition that reads as tolerant of a skipped dependency and is dead in exactly that case.* -SHA pins with version comments, the name-suffix rules, `set -Eeuo pipefail`, `if: >-`, registry-tag Docker cache with `cache-to` only the built branch on push and `cache-from` both branches, line endings per `.editorconfig`. +### D8 - Bots / Automation + +- **D8.1 Merge-bot.** Output: enables auto-merge on `opened`/`reopened` for **every** Dependabot tier including semver-major (the required checks are the gate, not the bump magnitude); dispatches `--squash`/`--merge` by the PR's base ref; disables on a maintainer-pushed `synchronize`; concurrency keyed on the **PR number**, not `github.ref`. *Prevents: two PRs colliding in auto-merge.* +- **D8.2 CodeGen and Dependabot.** Output: codegen runs as a matrix over both branches and is deterministic from an external source. `.github/dependabot.yml` targets both branches, and security PRs go to the default branch. +- **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it. The `main` pin push publishes via the release gate, while a `develop` pin does not auto-publish. It ships via a `develop` dispatch (prerelease) or the next promotion to `main`. The tracker's `bump-branch-prefix` + `branches` MUST match a merge-bot rule, one of the built-in `-` head/base pairs or a `rules` entry the caller passes, or auto-merge silently never fires. A tracker whose bump needs a human decision instead sets `auto-merge: false`, which prefixes the head so no merge-bot rule matches it, whatever `bump-branch-prefix` names. +- **D8.4 An identity allowlist used as a gate fails loud.** Where a gate compares `github.actor` (or a PR author) against hard-coded bot identities, the non-matching branch on an otherwise-legitimate trigger **emits a `::warning::`** rather than falling through silently. Output: a run that declines to act on an unrecognized identity is visibly annotated. *Prevents: the App being renamed, replaced, or reinstalled under a new slug, after which the comparison quietly evaluates false and the gate stops firing, a green and silent run that looks identical to a healthy one.* The masking matters most where a second path hides the loss: a weekly schedule keeps publishing, so the only symptom is release *timeliness*, easily missed for months. Where the failure is self-announcing instead (the merge-bot simply stops merging, so bot PRs visibly pile up) an annotation is optional. Resolving the identity at run time (mint an App token, read `GET /app`) removes the hard-coded string entirely and is the escalation if an allowlist proves fragile in practice. + +### D9 - Style / Static + +`GOVERNANCE.md` "Workflow YAML Conventions" names the tool D9.1 excepts and states the suffix rules D9.2 requires. + +- **D9.1** Every action or reusable workflow referenced from another repository is SHA-pinned with a version comment (sole exception: the documented lagging-tag tool). A local (`./`) or self-repository (`$/`) reference names no ref and takes no pin. +- **D9.2** File/workflow/job/step names follow the suffix rules. A ruleset-bound job's `name:` equals its ruleset `context:` (renamed together). +- **D9.3** Multi-line bash `run:` blocks start `set -Eeuo pipefail`. Multi-line `if:` uses `>-`. +- **D9.4** Docker layer cache targets a registry tag, not `type=gha`. `cache-to` writes only the built branch's `:buildcache-` and only on push, while `cache-from` reads both branches. A multi-image repo varies the cache **repository** rather than the tag, `:buildcache-` per image, the tag alone being unable to distinguish two images. +- **D9.5** Line endings follow `.editorconfig`. + +`WORKFLOW.md` section 4 keeps the D-guarantees, and the `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/references/d-guarantees.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries this section whole as a generated include. + + diff --git a/.github/skills/workflow-ci-contract/references/test-methodology.md b/.github/skills/workflow-ci-contract/references/test-methodology.md index ecd0948..f7690a0 100644 --- a/.github/skills/workflow-ci-contract/references/test-methodology.md +++ b/.github/skills/workflow-ci-contract/references/test-methodology.md @@ -1,27 +1,59 @@ # Testing a Repo's Workflows -The three escalating verification modes from `WORKFLOW.md` section 5, which keeps authority. N/A items (a check or scenario for an absent construct) are recorded and excluded, never failed. +The section below is `WORKFLOW.md` section 5, whole. Its items and scenarios answer to the D-guarantees in `WORKFLOW.md` section 4, carried whole in `d-guarantees.md` beside this file. -## 5A: Static Audit +## The Test Methodology -Read the workflow files plus `version.json` and assert the structural fact behind each applicable D-guarantee, each pass, fail, or N/A with a `file:line` citation, asserting each input in the layer that declares it. The core sweep covers: the paths-filter's target coverage and `.github/workflows/**` exclusion, smoke gating on every upload, the aggregator's `needs:` and skip/fail handling, the entry validation jobs and the two-directional release gate, the single-branch NBGV classification and the three default-branch literals agreeing, `target_commitish` from `GitCommitId`, the consume-then-delete artifact lifecycle with `retention-days: 1` everywhere and no blanket delete, the `pattern:` handoff and `inputs.branch` config, the publisher's serialized concurrency, and the SHA pins. `WORKFLOW.md` 5A lists the per-type addenda (console runtime matrix, NuGet `--skip-duplicate`, the PyPI OIDC environment split, Docker `expect_release_assets` and cache shape, the static-site deploy gates), so apply only the ones the repo's types imply. + -## 5B: Trace Scenarios +An agent verifies a project in three escalating modes, then renders a verdict. **Skip N/A items** (`WORKFLOW.md` section 1): a guarantee or scenario for an absent construct is recorded N/A, not failed. -For each applicable scenario, evaluate every job's `if:`/`needs:` against the inputs and compare the predicted run/skip, version, release, and artifact end state to the expected table in `WORKFLOW.md` 5B. The load-bearing ones: +### 5A. Static Audit (No Execution) -- **S1** a PR touching a target: that target smoke-builds, nothing uploads, the aggregator succeeds. -- **S5/S6** a bot push to `main`: publishes only when code-affecting, and a human push never does. -- **S7** a publish run builds the one trigger branch with the right classification and leaves no dangling artifacts. -- **S8** a dispatch from a ref other than `main`/`develop` fails fast. -- **S9** a no-op re-run: release-create skipped, registries dedupe, PyPI build artifact still deleted, Docker still re-pushes. -- **S10** branch and version classification disagree: the gate fails loud and everything downstream skips. -- **S12/S13** a deploy dispatch: ref gate first, environment re-asserted, pointer flip separate, live check names the release, and a production deploy from a non-default ref fails before anything is written. +Assert the structural fact each *applicable* D-guarantee implies, and record **pass**, **fail**, or **N/A** per item. This section says how an audit is run and recorded rather than what must hold: a guarantee names its own constructs, and the requirement is `WORKFLOW.md` section 4's item together with whatever that item defers to. -## 5C: Live Probe +Most of the evidence is in the workflow files and the composite actions they reach. Where a guarantee's evidence lies outside them, it is in practice the repo's branch ruleset, its Actions and Dependabot secret names, a workflow the repo only calls, a project or dependency file, or a committed file such as `version.json`, `.github/dependabot.yml`, `global.json`, `codecov.yml`, `.gitignore`, or `.editorconfig`. -Only for what a static trace cannot settle: a trivial PR to confirm S1, a smoke push-probe of both branches' version classification, registry queries after a real publish, and the artifact lifecycle read from a real run's logs. The deploy ref gate is verified only by tripping it, and that dispatch is the maintainer's to run: the agent prepares the command and reads back the four evidence items (gate conclusion, its error text, every downstream job skipped, deployment count unchanged), and a harness refusal to fire it is the control working, never something to re-shape. +Cite what each verdict rests on. That is `file:line` for a file in the audited repo, its own name where a setting, a ruleset, or a secret name rather than a file is the evidence, and `/@` plus the `file:line` in that repo where the guarantee binds a workflow or composite action the audited repo only reaches, read at the SHA the caller pins. An **N/A** verdict names the absent construct instead, there being no line to cite. -## Verdict +### 5B. End-to-End Trace Scenarios (No Execution, Deterministic from the YAML) -Operational iff every applicable 5A item passes and every applicable 5B scenario matches, with the failing guarantees and their triggering inputs named, and the N/A list recorded. Per-project-type walkthroughs mapping scenarios onto targets, including source-only, static-site, and operational shapes, are `WORKFLOW.md` section 6. +For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the inputs and emit the predicted **run/skip + version + release + artifact-end-state** table, then compare to the expected. A scenario governing a construct the repo does not contain is N/A, per `WORKFLOW.md` section 1, and an absent trigger is such a construct. Each scenario's trigger belongs to one workflow, so read that workflow's own `on:` block rather than the repo's type: S1 to S4 the pull request workflow's, S5 to S10 the publisher's, S11 the upstream tracker's, and S12 and S13 the deploy workflow's. A publisher carrying only `workflow_dispatch` therefore records S5, S6 and S9 N/A, their push and schedule paths never firing there, and a repo with no publisher at all records S5 to S10 N/A together. Where a scenario's path runs through a workflow or composite action the repo only **calls**, trace that callee as the repo reaches it, read at the SHA the caller pins rather than at the callee's current default branch, which is the same evidence rule 5A states. Predicting from the callee's `main` predicts a table for YAML the audited repo never runs. A local (`./`) or self-repository (`$/`) call carries no pin of its own and runs at the workflow commit, so it is traced at whatever SHA the outermost pinning caller fixed. Minimum set: + +| # | Input | Expected output | Exercises | +| --- | --- | --- | --- | +| S1 | PR touching a build target | `changes` flags it; validation runs; that target's smoke build runs; no push, **no uploads**; validate-release **succeeds**, its check exiting early on smoke per D2.2; release **skipped**; aggregator **success**; version = prerelease; no release; no dangling artifacts | D1, D2.2, D3 | +| S2 | PR changing only docs | smoke-build **skipped**, validation runs, aggregator **success** | D1.1, D1.2, D1.5 | +| S3 | PR changing only `.github/workflows/**` | the filter marks no target -> smoke-build **skipped**, validation runs, aggregator **success** | D1.2, D1.4, D1.5 | +| S4 | PR base = default branch, carrying a build target | smoke versions as prerelease, validate-release **succeeds** with its check exited early per D2.2, so the default-branch arm does **not** fire, aggregator **success**, promotion not blocked | D1.5, D2.2, D3.2 | +| S5 | bot push to `main` not touching a release path (e.g. an Actions bump) | the paths filter excludes it, so nothing publishes | D4.1 | +| S6 | code-affecting **bot** push to `main` (a human push/promotion, or any develop push, does not) | the `plan` job gates it to the App/Dependabot actor, and `main` publishes a release | D3, D4 | +| S7 | publish run (schedule, a bot push to main, or a dispatch) | builds the **one** trigger branch: `main` -> `X.Y.Z`, `prerelease=false`, registry stable, readme run; `develop` -> `X.Y.Z-g`, `prerelease=true`, registry prerelease; `release-asset-*` consumed-then-deleted; each package build-artifact (`nuget-build-*`, `pypi-build-*`) deleted after its publish; **no dangling artifacts** | D3, D4, D5, D6, D7 | +| S8 | dispatch from a ref other than `main` or `develop` | **fails fast** | D2.3 | +| S9 | re-run publish on a schedule or push trigger, version unchanged (a dispatch re-run refreshes the release instead, per D4.4) | release-create **skipped**, `release-asset-*` delete **skipped**; NuGet/PyPI pushes no-op (server dedupe); **package build-artifacts still deleted** (their download succeeded); **Docker still re-pushes** the image; no duplicate release | D4.4, D5.2 | +| S10 | branch/version classification disagree | validate-release **fails loud**, build/publish skip | D2.2 | +| S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a per-branch bump PR -> the merge-bot auto-merges it, or leaves it for the maintainer where the tracker sets `auto-merge: false` (D8.3) -> the `main` pin publishes via the gate (a develop pin does not auto-publish, shipping instead via a develop dispatch or promotion) | D8.3, D3.5 | +| S12 | deploy dispatch naming an environment | the ref gate runs **first** (production from the default branch only, any ref to a non-production environment); validation runs; the callee re-asserts the environment name; a release installs under its own id; the pointer flips as a separate step; retention is bounded by whichever of the two D5.6 shapes the repo uses, so a deploy whose credential can observe the destination asserts the count converged and one confined write-only leaves it to the host; the live check asserts the environment and the release id, waiting out the reload, then the URL contract; **no tag and no release are created** | D2.1, D4.6, D5.6 | +| S13 | deploy dispatch of a production environment from a non-default ref | **fails fast**, before anything is installed or written | D2.1 | + +### 5C. Live Probe (Where Warranted) + +Every probe here that opens a pull request, dispatches a workflow, or re-runs a real publish is the maintainer's to run, with the agent preparing the command and reading the result back afterwards. A harness that refuses such a write is the harness working as intended, and the refusal is neither re-shaped into a raw API call nor talked around (`GOVERNANCE.md` "Repository Boundaries and Write Safety"). + +- Open a trivial-change PR touching one target and confirm S1. *Caveat: the Docker leg logs in to the registry even on smoke and reads the buildcache, so it needs `DOCKER_HUB_*` secrets and cannot run on a fork PR (same-repo only).* +- Per registry: after a real publish, query NuGet.org for the expected version + prerelease classification (and the `.snupkg` on the symbol server), and confirm a re-run added no duplicate. For PyPI read the built `dist/*` filenames out of the build job's log, `.dev0` off `develop` vs a plain version on the default branch. +- Inspect the latest real publish's logs for `PublicRelease`/`SemVer2` per leg and confirm the artifact lifecycle (uploaded, consumed, deleted, with none left behind). +- **The deploy ref gate (S13) is verified only by tripping it.** Dispatch the production environment from a non-default ref and expect the run to fail at the gate. The evidence is four things, and each of them matters: the gate job's conclusion, its error text naming the expected and the received ref, every downstream job recorded as **skipped** rather than passed, and the production environment's deployment list carrying no deployment from the dispatched ref. Capture all four, because a gate that fails open and a gate nobody tripped produce the same empty run history, so "we have never seen it fail" is not evidence about the one control standing between a mis-dispatch and the live site. **The agent prepares the command and reads all four back afterwards. It does not fire it.** The same split applies to any probe that acts on the deploy host directly, an outbound SSH exercising a forced command among them. + +### Assessment + +Record the workflow **operational** when every *applicable* 5A item passes, every *applicable* 5B scenario's predicted output equals the expected, and no 5C probe that was run contradicts either. N/A items are excluded, never counted as failures. Any *applicable* mismatch is a **defect** -> **not operational**. Procedure: + +1. **Audit** with 5A, recording each item's verdict and its evidence in the form 5A sets out. +2. **Trace** the applicable S-scenarios with 5B. Diff predicted vs expected. +3. **Probe** with 5C where a live signal exists that the static trace cannot produce, running the probes that only read and preparing the writing ones for the maintainer: live version classification, registry state, the artifact lifecycle of a real run, and the deploy ref gate. +4. **Verdict:** operational / not operational, with the failing guarantee(s) and the triggering input for each, the list of items recorded N/A, and the 5C probes prepared but not run. + +`WORKFLOW.md` section 5 keeps the test methodology, and the `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/references/test-methodology.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries this section whole as a generated include. + + From a6ad7fc9d6fd7c9f70867cef3db80d239e975c38 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 25 Sep 2026 19:23:47 -0700 Subject: [PATCH 3/3] Re-vendor CODESTYLE.md, AUDIT.md and Lint Config From the Hub Keeps this repository's own LanguageData rules in .editorconfig and .gitattributes. Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitattributes | 2 +- .markdownlint-cli2.jsonc | 10 +++- AUDIT.md | 103 +++++++++++++++++++++++++++++++-------- CODESTYLE.md | 10 ++-- 4 files changed, 96 insertions(+), 29 deletions(-) diff --git a/.gitattributes b/.gitattributes index 5859dde..7501834 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,7 +2,7 @@ # `text=auto` leaves binary files byte-preserved. * text=auto eol=lf -# Windows batch and command scripts require CRLF. +# Windows command scripts require CRLF. *.bat text eol=crlf *.cmd text eol=crlf diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index ed90f84..e433177 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -3,6 +3,7 @@ // Prose paragraphs and data-heavy tables or URLs are intentionally long. // Reflowing at 80 columns hurts readability and churns diffs. "MD013": false, + "MD024": { "siblings_only": true }, // MD033 (inline HTML) stays enabled so native Markdown wins. // HTML comments, used as reference-link dividers, pass it. // The details and summary elements are allowed for GitHub collapsibles, which have no Markdown equivalent. @@ -14,5 +15,12 @@ // (`|a|b|`) and padded (`| a | b |`) table pipe spacing. "MD060": false }, - "gitignore": true + "gitignore": true, + // Declared here rather than only as the CI action's globs input, because the ignores entry below only subtracts, so without a positive glob a run lints nothing and exits clean. + "globs": ["**/*.md"], + // Third-party Markdown under node_modules is not authored prose, and a repository's .gitignore does not reliably exclude it. + // A bare `node_modules` entry would match the root copy only. + // This list is fleet-wide. A repository excluding a subtree of its own adds no entry here, and puts a `.markdownlint-cli2.jsonc` carrying its own `ignores` beside that content instead, which a bare local run and CI both honor. + // Those `ignores` patterns resolve against the directory holding that file rather than against the repository root, so each entry names its target as seen from that directory and a repository-root path matches nothing there, reporting no error saying so. + "ignores": ["**/node_modules/**"] } diff --git a/AUDIT.md b/AUDIT.md index 4377773..6ddf448 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -12,7 +12,7 @@ flowchart TD s0m["0m: fleet membership, every owned non-fork repo has a registry entry"] --> s0["0: has the repo been stood up? if not, STANDUP.md"] s0 --> s1["1: scope, ground-truth branch (main)"] s1 --> s2["2: resolve the repo's type(s)"] - s2 --> s3["3: applicability gate, per check"] + s2 --> s3["3: applicability gate, per item or check"] s3 --> s4["4: per-dimension checks, letter and intent"] s4 --> s5["5: assert Actions implement WORKFLOW.md"] s5 --> s6["6: validate settings, rulesets, secrets"] @@ -29,14 +29,14 @@ flowchart TD This audit is not occasional. Run it whenever you **create, adopt, or materially change** a fleet repo, and on demand for any known repo: -- **A full sweep opens with a fleet membership check, not a per-repo one.** `spec/audit.py`, run with no repo names, first lists every non-fork repository the registry `owner` actually owns on GitHub and diffs it against `registry/repos.json`. A repo that exists but carries no entry is invisible to every other check in this file, since all of them iterate the registry and never look past it, so this is the only place that gap is caught (ptr727/ProjectTemplate#550). The check also reconciles one field: a registry `status: "archived"` must agree with GitHub's own archived flag, in either direction. A name-filtered run or `--issue` skips it, since those are scoped to repos already known to the registry. Run this as `gh auth login` for the owner's own account: a fine-grained PAT scoped to "selected repositories" returns an incomplete list with no error, so the sweep would read clean while some repos were never inspected. +- **A full sweep opens with a fleet membership check, not a per-repo one.** `spec/audit.py`, run with no repo names, first lists every non-fork repository the registry `owner` actually owns on GitHub and diffs it against `registry/repos.json`. A repo that exists but carries no entry is invisible to every other check in this file, since all of them iterate the registry and never look past it, so this is the only place that gap is caught. The check also reconciles one field: a registry `status: "archived"` must agree with GitHub's own archived flag, in either direction. A name-filtered run or `--issue` skips it, since those are scoped to repos already known to the registry. Run this as `gh auth login` for the owner's own account: a fine-grained PAT scoped to "selected repositories" returns an incomplete list with no error, so the sweep would read clean while some repos were never inspected. - **Onboarding a repo is complete only when it either passes this audit** (operational on every applicable check) **or carries a committed `reports//audit.md` plus a tracking issue** enumerating every residual delta. A repo that is partially set up but never audited is itself a **defect**, the exact state this process prevents. The create-to-conformance counterpart is [`STANDUP.md`][standup]. Because both read the same manifests, a repo stood up by that file passes this audit by construction. -- **Touching a repo** (any conformance-affecting change) ends by re-running the applicable checks and **reconciling the registry entry to reality**: `status`, `types`, `releaseTrigger`, `workflowModel`, `driftNotes`. The registry records reality, not intent. [`spec/validate.py`][validate] proves the catalog is self-consistent, not that it matches the live repo. Closing that gap is this audit's job. The deterministic subset (settings, rulesets, secret names, file presence, per-scope Markdown section presence, workflow interface conformance, verbatim content, hub-hosted files a repo carries, branch facts) is mechanized in [`spec/audit.py`][audit-runner]: owner-initiated, run on demand when onboarding a repo, on suspected drift, or before fleet-wide changes. A required section missing from a carried Markdown file is a **drift finding**, not a letter, because a heading rename reads as missing and equivalence is judged by hand. A carried `interface` workflow (spec/fidelity-model.md) is checked by name and wiring (required jobs, the ruleset-bound check name, the artifact-name handoff, and the forbidden `artifact-ids:` fork), all at **drift**, since the body is owned and a rename is a hint to verify. A carried `verbatim` unit, whether a whole file (`.markdownlint-cli2.jsonc`) or a canonical workflow job region (the `github-release` job), is content-hashed against the hub's canonical after line-ending normalization. A mismatch is classified **stale** (matches a past hub revision, re-vendor) or **modified** (matches none, the repo changed fixed content), both at **drift**, since equivalence is intent-governed and a byte diff is a hint to review. A carried `intent` unit gets one advisory beyond presence, a last-modified comparison: a hub canonical changing after the copy's own last commit marks the copy as possibly trailing, at **drift**, a hint rather than proof, since a copy touched without reconciling reads current and content is never judged. +- **Touching a repo** (any conformance-affecting change) ends by re-running the applicable checks and **reconciling the registry entry to reality**: `status`, `types`, `releaseTrigger`, `workflowModel`, `driftNotes`. The registry records reality, not intent. [`spec/validate.py`][validate] proves the catalog is self-consistent, not that it matches the live repo. Closing that gap is this audit's job. The deterministic subset (settings, rulesets, secret names, file presence, per-scope Markdown section presence, workflow interface conformance, verbatim content, hub-hosted files a repo carries, branch facts) is mechanized in [`spec/audit.py`][audit-runner]: owner-initiated, run on demand when onboarding a repo, on suspected drift, or before fleet-wide changes. A required section missing from a carried Markdown file is a **drift finding**, not a letter, because a heading rename reads as missing and equivalence is judged by hand. A carried `interface` workflow (spec/fidelity-model.md) is checked by name and wiring (required jobs, the ruleset-bound check name, the artifact-name handoff, and the forbidden `artifact-ids:` fork), all at **drift**, since the body is owned and a rename is a hint to verify. A carried `verbatim` unit, whether a whole file (`.markdownlint-cli2.jsonc`) or a canonical workflow job region (the `github-release` job), is content-hashed against the hub's canonical after line-ending normalization. A mismatch is classified **stale** (matches a past hub revision, re-vendor) or **modified** (matches none, the repo changed fixed content), both at **drift**, since equivalence is intent-governed and a byte diff is a hint to review. A carried `intent` unit gets one advisory beyond presence, a last-modified comparison: a hub canonical changing after the copy's own last commit marks the copy as possibly trailing, at **drift**, a hint rather than proof, since a copy touched without reconciling reads current and content is never judged. A content scan also looks for a version literal: a three-part version, a full 40-character commit SHA, or an abbreviated one of 7 to 12 characters mixing digits and letters, in `AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, or `WORKFLOW.md`, outside the verbatim sections downstream and across the whole file in the hub, is a **drift** finding, since a pin's value in prose goes stale at the next bump and every other literal reads like one. **Verify the host before running any hub tool.** The tools carry version floors, and a host below one answers `--version`, looks healthy, and produces a wrong answer, so a clean audit run from a broken host is a clean-looking result rather than a result. ```shell -python3 scripts/host_gate.py --repo # run from a hub checkout, floors from spec/host-tools.json +python3 scripts/host_gate.py --repo "" # run from a hub checkout, floors from spec/host-tools.json ``` Pass `--repo`, since the gate reads the target's own `host-tools.json` relative to it and defaults to the working directory. Omitting it does not read the target's declaration at all, so every floor that repo adds goes unapplied, and the run reports nothing about the omission. A finding is a **host** misconfiguration rather than a repo one, and [`docs/host-setup.md`][host-setup] is the contract it checks. @@ -61,7 +61,7 @@ Otherwise read its `types[]`. If the entry is `classificationPending` (a backlog ## 3. Applicability Gate -Reuse [`WORKFLOW.md`][workflow] section 1: a check that governs a construct the repo does not contain is **N/A**. Record it as N/A and **exclude it from the verdict**. N/A is never a defect. A Docker check on a repo with no image, a NuGet check on a Python package, and the artifact-lifecycle clauses on a source-only repo are all N/A. +Reuse [`WORKFLOW.md`][workflow] section 1, extended to this audit's own checks: an item or check that governs a construct the repo does not contain is **N/A**. Record it as N/A and **exclude it from the verdict**. N/A is never a defect. A Docker check on a repo with no image, a NuGet check on a Python package, and the artifact-lifecycle clauses on a source-only repo are all N/A. Which carried files and sections a repo is expected to have is decided by its scope selectors (its type(s) plus workflow model, release trigger, and consumer model). The scope model and the `appliesTo` selector vocabulary are defined in [`spec/scope-model.md`][scope-model]. @@ -88,36 +88,96 @@ A check with `intentRef`/`workflowRef` points at the prose section that owns the - **verbatim-tree** - every applicable `trees[]` declaration in [`spec/files.json`][files] owns its target tree. The audit reports missing files as letter findings, stale or modified bytes as drift, and extra files under a pruned target as drift. An unreadable or truncated repository tree is undecided and produces drift rather than a clean result. - **repo-setup** - every required secret for the repo's publish mechanisms is configured, and no forbidden secret is present (per [`spec/secrets.json`][secrets]). - **runtime-secrets** - a repo-scoped runtime-secrets directory, when present, is named `.secrets/` (dotted, not a bare `secrets/`), a single opaque credential file carries no extension, and every real secret file has a tracked `.example` beside it cataloged in `.secrets/README.md`. N/A for a repo with no such directory. See [GOVERNANCE.md "Repo-Scoped Secrets"][governance-repo-scoped-secrets]. -- **linter-parity** - one config per linter (`.markdownlint-cli2.jsonc`, `cspell.json`, ruff/pyright, editorconfig/csharpier, actionlint) drives the editor extension, the CLI, and CI, and CI runs each. A local hook exists and runs at minimum the diff-scoped prose gate and the eol check via `hub-fetch-run.py`, or the hub's own local script copies for the hub repo itself (`parity.hooks`, intent). A repo with none wired is a defect, and one mid-convergence on the language-formatting half stays operational. +- **linter-parity** - one config per linter (`.markdownlint-cli2.jsonc`, `cspell.json`, ruff/pyright, editorconfig/csharpier, actionlint) drives the editor extension, the CLI, and CI, and CI runs each. That count is of the root config, so a nested `.markdownlint-cli2.jsonc` is not a second one. A local hook exists and runs at minimum the diff-scoped prose gate and the eol check via `hub-fetch-run.py`, or the hub's own local script copies for the hub repo itself (`parity.hooks`, intent). A repo with none wired is a defect, and one mid-convergence on the language-formatting half stays operational. - **recurring-violations** - comments concise and non-narrative, ASCII only (no em-dash, no smart quotes), US spelling, line endings per `.editorconfig`. These are frequent regressions, so this dimension is high priority and always runs, and each check is grep-able (see below). - **readme-structure** - the README follows [`spec/readme-structure.md`][readme-structure] (applicable sections, in order). Mechanically checked against the declared model in [`spec/readme-sections.json`][readme-sections]: required sections present, declared sections in their relative order, `License` last, the shields each deliverable implies, the license shield in the closing License section, and the tagline and its mirrors. A heading the model does not name is dropped before the order comparison, so a repo-specific section is never a finding. ## 5. Assert the Actions Implement WORKFLOW.md -Run [`WORKFLOW.md`][workflow]'s methodology against the repo's **own** Actions: the 5A static audit (structural facts per applicable D-guarantee, each with a `file:line` citation) and the 5B trace scenarios (predicted run/skip + version + release + artifact-end-state vs expected). The contract in WORKFLOW.md section 4 is satisfied by **outcome**, not by matching the catalog snippets in [`catalog/snippets/workflows/`][workflows] byte for byte. Those are the reference implementation, not required bytes. +Run [`WORKFLOW.md`][workflow]'s methodology against the repo's **own** Actions, reading a workflow it only calls at the SHA it pins: the 5A static audit (structural facts per applicable D-guarantee, each cited in the form 5A sets out) and the 5B trace scenarios (predicted run/skip + version + release + artifact-end-state vs expected). The contract in WORKFLOW.md section 4 is satisfied by **outcome**, not by matching the catalog snippets in [`catalog/snippets/workflows/`][workflows] byte for byte. Those are the reference implementation, not required bytes. Where a guarantee names a construct, D6.1's `release-asset--` and D9.2's ruleset-bound job `name:` among them, that name is the outcome and a divergence is a **defect** here. That is a separate judgment from the `verbatim` content hash section 0 describes, which classifies a mismatch as stale or modified and reports either at **drift**, since equivalence is intent-governed and a byte diff is a hint to review rather than a verdict. ## 6. Validate Settings, Rulesets, and Secrets -- **General settings and rulesets** - fetch the hub and check out `main`. Run `repo-config/configure.sh check / release|operational` from that checkout. Pass the target repository and its registry `workflowModel` explicitly. The command checks the shared settings, state-dependent settings, Dependabot security features, and both rulesets against the hub payloads. It preserves and reports `bypass_actors` without asserting them because bypass authority is a per-repository human decision. +- **General settings, the registry description, labels, rulesets, the Dependabot security features, the fleet project link, and deployment environments** - fetch the hub and check out `main`. Run `repo-config/configure.sh check "/" ""` from that checkout. Pass the target repository and its registry `workflowModel` explicitly. The token needs project access as well as the admin the ruleset endpoints require, since a token without it cannot read the project link and the command reports that group as failing rather than as clean, and the command's own error names the scope to grant. The host needs a runnable `py -3` or `python3` too, since a run against a hub checkout resolves the registry description through [`spec/resolve_description.py`][resolve-description] and exits before the first check when neither interpreter answers. The command checks what `configure.sh apply` writes: the declared settings, the derived settings and the registry description, the declared labels, the Dependabot security features, the shared `main` ruleset, the `develop` ruleset the model selects, and the link to the fleet project the hub declares. It reports the live `bypass_actors` list without asserting it, because bypass authority is a per-repository human decision. It also checks one group apply never writes, the deployment environments the registry's `environments` declares for the repo: each one exists, its deployment-branch policy is the form declared, and under a `custom` policy the entries it allows are exactly the declared branches, so anything added by hand reads as drift. An environment the registry declares nothing about is reported rather than asserted, since GitHub creates some on its own. Neither this command nor the **Secrets** bullet below queries an environment's own secret and variable stores, where the API exposes a secret's name but not its value and a variable's name and value both, so a clean run says nothing about whether an environment holds what a deploy needs. -- **Secrets** - from the same hub checkout, run [`spec/audit.py`][audit-runner] `[repo]` and read its Secrets section. It resolves the required set from the hub's own [`spec/secrets.json`][secrets] plus the registry entry's `publish[]`/`types[]`/`requiredSecrets[]`, confirming each required name exists (name only, not the values) in the Actions store and, where the mechanism needs it (Docker Hub, codegen App), the Dependabot store too. +- **Secrets** - from the same hub checkout, run [`spec/audit.py`][audit-runner] `""` and read the findings it prints with a `secrets:` prefix. That argument is the registry entry name rather than the `/` form `configure.sh` takes, and an `/` argument prints `Not cataloged` and exits 2. The runner reads both the Actions and the Dependabot store on every repo, and a token that cannot read either one fails that repo's whole run with an error rather than reporting names as missing. It resolves what each store must hold from the hub's own [`spec/secrets.json`][secrets] plus the registry entry's `publish[]`/`types[]`/`requiredSecrets[]`. That file's `baseline` requires its names in both stores for every fleet repo, and a mechanism adds to a store only where the mechanism's own `stores` list names that store. A declared type claims nothing where the entry's profile for it reads `lint-only`, and claims no coverage token where the tree carries no tests for it, so a repo declaring a language can still owe nothing beyond the baseline. It reports three things: a required name missing from a store, a forbidden name present in either store, and a configured name no applicable mechanism claims. A forbidden name present draws the third as well as the second, since the claimed set is built from the required names alone. Names are read, never values. -- **Dependabot ecosystem coverage** - for each ecosystem the repo's tree implies, `.github/dependabot.yml` must declare it: `github-actions` when `.github/workflows/` is present (its workflows reference actions, and otherwise those versions go stale and a stood-up merge-bot has no action-update PRs to auto-merge), and `devcontainers` when a `.devcontainer` is present. The mechanical check (`spec/audit.py`) asserts each implied ecosystem's **presence**. A tree-implied ecosystem declared nowhere is a **drift finding** (the file exists, so its absence would instead be a file-presence letter). Then confirm **by inspection** that each declared ecosystem **dual-targets `main` + `develop`** per the [Branching Model][governance-branching-model], since the regex below cannot pair an ecosystem with its `target-branch`. Language ecosystems (`nuget`/`uv`/`npm`) are directory-scoped and audited by inspection too. +- **Dependabot ecosystem coverage** - for each ecosystem the repo's tree implies, `.github/dependabot.yml` must declare it: `github-actions` when `.github/workflows/` holds at least one `.yml` or `.yaml` entry (those workflows reference actions, and otherwise those versions go stale and a stood-up merge-bot has no action-update PRs to auto-merge), and `devcontainers` when a `.devcontainer` is present. The mechanical check (`spec/audit.py`) asserts each implied ecosystem's **presence**, and it runs at all only where `.github/dependabot.yml` exists and is non-empty. A missing `dependabot.yml` is a file-presence letter of its own, and an empty one is caught by neither check, since the file-presence check reports only absence, so an empty file is reported by hand. A tree-implied ecosystem that file declares nowhere is a **drift finding**. Then confirm **by inspection** that each declared ecosystem **dual-targets `main` + `develop`** per the [Branching Model][governance-branching-model], since the regex below cannot pair an ecosystem with its `target-branch`. Language ecosystems (`nuget`/`uv`/`npm`) are directory-scoped and audited by inspection too. ```bash - # Anchor to the line start (optional list dash) so a commented-out '# package-ecosystem:' is not counted. - dependabot_content=$(gh api "repos///contents/.github/dependabot.yml?ref=" --jq '.content') || exit 1 - dependabot_yaml=$(base64 -d <<<"$dependabot_content") || exit 1 - decl=$(grep -oE '^[[:space:]]*-?[[:space:]]*package-ecosystem:[[:space:]]*"?[a-z-]+' <<<"$dependabot_yaml" | grep -oE '[a-z-]+$' | sort -u) - root_paths=$(gh api "repos///contents?ref=" --jq '.[].path') || exit 1 - github_paths=$(gh api "repos///contents/.github?ref=" --jq '.[].path') || exit 1 + #!/usr/bin/env bash + # Save and run this as a script rather than pasting it into a shell, since it exits rather than returns. + # It prints one line per implied ecosystem, and its exit status reports whether the reads succeeded rather than whether an ecosystem is missing. + set -Eeuo pipefail + repo="/" + ground=main # The branch section 1 reads as ground truth. + + root_paths=$(gh api "repos/$repo/contents?ref=$ground" --jq '.[].path') + # A repo carrying no .github directory would 404 on the listing below, which is an absence rather than the read failure that exit code otherwise means. + github_paths="" + if grep -Fxq .github <<<"$root_paths"; then + github_paths=$(gh api "repos/$repo/contents/.github?ref=$ground" --jq '.[].path') + fi has() { grep -Fxq "$1" <<<"$root_paths"$'\n'"$github_paths"; } - has .github/workflows && { grep -qx github-actions <<<"$decl" && echo "github-actions: present" || echo "github-actions: MISSING (workflows present)"; } - has .devcontainer && { grep -qx devcontainers <<<"$decl" && echo "devcontainers: present" || echo "devcontainers: MISSING (.devcontainer present)"; } - # then read dependabot.yml and confirm each present ecosystem has both a main and a develop target-branch entry + + if ! has .github/dependabot.yml; then + echo "dependabot.yml absent: a file-presence finding, and ecosystem coverage is not checked at all" + exit 0 + fi + # The raw media type answers with the file itself, so nothing here decodes base64 or reads a content key that can be absent. + dependabot_yaml=$(gh api "repos/$repo/contents/.github/dependabot.yml?ref=$ground" -H "Accept: application/vnd.github.raw") + # The mechanical check gates on a non-empty file, so an empty one skips here too rather than reporting every implied ecosystem missing. + if [ -z "$dependabot_yaml" ]; then + echo "dependabot.yml empty: the mechanical check skips it and the file-presence check reports only absence, so report this one by hand" + exit 0 + fi + # Anchor to the line start (optional list dash) so a commented-out '# package-ecosystem:' is not counted. + # Accept either quote or none, as the mechanical check does. + # The trailing class is the mechanical check's own, so a value carrying a digit is read whole rather than truncated to its leading letters. + eco_re="^[[:space:]]*-?[[:space:]]*package-ecosystem:[[:space:]]*[\"']?[[:alnum:]_-]+" + # A file declaring no ecosystem at all matches nothing, and grep exiting 1 would otherwise abort the run under the header above. + # The two greps are separate statements so that only a no-match is absorbed. + # Chained through a pipe they are not, because a failing first stage produces no output, the second stage then no-matches, and pipefail reports that exit 1 rather than the failure. + matched=$(grep -oE "$eco_re" <<<"$dependabot_yaml") || [ "$?" -eq 1 ] + decl=$(grep -oE '[[:alnum:]_-]+$' <<<"$matched" | sort -u) || [ "$?" -eq 1 ] + + if has .github/workflows; then + # The contents API answers with an object rather than an array where the path is a file, so the type is tested rather than assumed. + yaml_count=$(gh api "repos/$repo/contents/.github/workflows?ref=$ground" --jq 'if type == "array" then [.[] | select(.name | test("\\.ya?ml$"))] | length else 0 end') + if [ "$yaml_count" -gt 0 ]; then + grep -qx github-actions <<<"$decl" && echo "github-actions: present" || echo "github-actions: MISSING (.github/workflows/ holds a workflow file)" + fi + fi + if has .devcontainer; then + grep -qx devcontainers <<<"$decl" && echo "devcontainers: present" || echo "devcontainers: MISSING (.devcontainer present)" + fi + # Then read dependabot.yml and confirm each present ecosystem has both a main and a develop target-branch entry. + ``` + +- **Dependabot on self-hosted runners (owner setting)** - the owner-level toggle named `Dependabot on self-hosted runners`, which for a user-account owner sits at `https://github.com/settings/security_analysis`, routes Dependabot's own update jobs to a self-hosted runner pool. With none registered on the owner, those jobs queue for up to 24 hours, then get cancelled, and ordinary CI is unaffected throughout. GitHub routes only a private repo through this setting, which is why the fleet's public repos updated throughout while its private ones stalled, so read the audited repo's visibility first with `gh repo view "/" --json visibility` and treat a public one as N/A. On a private repo, read the newest Dependabot run's job: + + ```bash + #!/usr/bin/env bash + # Save and run this as a script rather than pasting it into a shell, since it exits rather than returns. + set -Eeuo pipefail + repo="/" + + # Dependabot's own update jobs run under the dynamic event rather than from a workflow file, so they are selected by path. + # Read the newest run rather than the history, since a repo whose account setting has since been turned off keeps every run cancelled while it was on. + # The dynamic event is shared with GitHub's other generated runs, Copilot's reviewer among them, which on an active repo fill whole pages, so the pages are walked rather than the first alone, within the 1000 runs this endpoint returns at most. + # The run's date is carried out with its id so the report can say when this repo's updates last ran, which the finding below does not turn on. + runs=$(gh api --paginate "repos/$repo/actions/runs?event=dynamic&per_page=100" --jq '.workflow_runs[] | select((.path // "") | startswith("dynamic/dependabot")) | "\(.id) \(.created_at)"') + if [ -z "$runs" ]; then + echo "no Dependabot run in this repo's dynamic run history" + exit 0 + fi + # The endpoint answers newest first and paginates in that order, so the first line is the newest Dependabot run whichever page it landed on. + run=$(head -n 1 <<<"$runs") + echo "newest Dependabot run: $run" + gh api "repos/$repo/actions/runs/${run%% *}/jobs" --jq '.jobs[] | "\(.conclusion) labels=\(.labels | join(",")) runner=\(.runner_name // "") steps=\(.steps | length)"' ``` -- **Dependabot on self-hosted runners (account setting)** - a repo whose `dependabot-updates` or `update-graph` workflow runs are all `cancelled` with zero steps has this problem. The account-wide toggle at `https://github.com/settings/security_analysis`, `Dependabot on self-hosted runners`, routes Dependabot's own update jobs to a self-hosted runner pool. With none registered on the account, those jobs queue for up to 24 hours, then get cancelled. The cancelled-with-zero-steps pattern above is the only Actions-API-visible signal, not an explicit cause, and ordinary CI is unaffected. The account-setting root cause surfaces only as a `Self-hosted runner unavailable` message on the repo's own Dependabot page. GitHub never routes a public repo through this setting, so `ProjectTemplate` itself cannot show the symptom (ptr727/ProjectTemplate#1015). Detection stops there, like the rest of this audit. Remediation is a separate, manual action. Confirm the toggle, and `Automatically enable for new repositories` beside it, are both off, or register a matching self-hosted runner instead of disabling it. Disabling the toggle does not rerun jobs already queued. Each affected repo still needs its own manual `Check for Updates` click on its own Dependabot page. + A job reading `labels=dependabot` with no runner name was routed to the pool, its empty step list saying it never started, where a job GitHub's own runners took reads `labels=ubuntu-latest` with a runner name and the steps it ran. Those four fields together are the finding: a newest run that was routed and then cancelled having run no step says this repo's Dependabot updates last failed that way and have not succeeded since, because a later success would be the newest run instead. Why they are still failing is not something the API answers, since the toggle's own state, whether a matching runner is registered and online, and whether the queue was ever restarted all sit outside it. The audit reports the repo and that run's date, and leaves the choice of remedy to the maintainer. Detection stops there, like the rest of this audit. Every remedy here is an owner-level or web-UI action no target-repo pull request can carry, so this is reported rather than converged under section 10: turn the toggle off, along with `Automatically enable for new repositories` beside it, or bring a matching self-hosted runner online instead. Disabling the toggle reruns nothing already queued, so an affected repo also needs its own manual `Check for Updates` click on its own Dependabot page, and a repo whose toggle is already off needs only that click. ## 7. Verdict Model @@ -154,7 +214,7 @@ flowchart LR finding -->|"one repo"| s10["10: Converge, branch + fix + PR"] s10 --> review["review loop to green"] review --> merge["maintainer merges"] - merge --> reaudit["re-audit, commit the report"] + merge --> reaudit["re-audit, the hub commits the report"] s9 --> reaudit ``` @@ -193,6 +253,7 @@ The convergence model: the hub audits and the agent **applies** the fixes via ta [repo-config]: https://github.com/ptr727/ProjectTemplate/tree/main/repo-config [reports]: https://github.com/ptr727/ProjectTemplate/tree/main/reports [repos]: https://github.com/ptr727/ProjectTemplate/blob/main/registry/repos.json +[resolve-description]: https://github.com/ptr727/ProjectTemplate/blob/main/spec/resolve_description.py [resync]: https://github.com/ptr727/ProjectTemplate/blob/main/RESYNC.md [scope-model]: https://github.com/ptr727/ProjectTemplate/blob/main/spec/scope-model.md [secrets]: https://github.com/ptr727/ProjectTemplate/blob/main/spec/secrets.json diff --git a/CODESTYLE.md b/CODESTYLE.md index 9d545f3..a18c456 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -18,7 +18,7 @@ Each language defines a **clean-compile** verification: the combination of build - **Run it after every code change, and it is not the whole gate.** The relevant language's clean-compile must pass before you commit. CI runs those same language checks as a backstop **plus everything else its validation workflow runs**, and all of it reports into the one required status, so a green clean-compile does not predict a green CI. That remainder is at least the doc-lint set (markdownlint, cspell, actionlint, `editorconfig-checker`) and whatever spec, config, and script gates the repo carries, so read the workflow for the full list rather than assuming this sentence enumerates it. What has to pass before a push is the repo's **whole** lint gate, per [GOVERNANCE.md "Verification Discipline"][governance-verification-discipline]. Each linter's known-working invocation is in `GOVERNANCE.md` "Running the Linters Locally (Known-Working Invocations)", a hub-only section read in a hub checkout rather than carried into every fleet repo. - **The named task definition is the canonical spec** - its exact command sequence, arguments, and strictness. You may run it through the VS Code task **or** by invoking the equivalent native commands directly, and either is fine **only if the sequence, arguments, and strictness match exactly**. No shortcuts and no more-lenient options (for example, never drop `--verify-no-changes` or loosen a `--severity`). -- **A working local commit/pre-commit gate is strongly suggested, not the repo's free choice to skip.** No single hook runner fits every language (a `dotnet`-tool runner like Husky.Net suits .NET but not Python), so the *mechanism* stays the repo's choice: Husky.Net for .NET, `pre-commit` for Python, canonical configs for both in `catalog/snippets/` in the hub, not a repo-relative path since it is hub-local and not carried into every fleet repo. What that gate must cover, and what its absence means for the audit, is `GOVERNANCE.md` "Running the Linters Locally (Known-Working Invocations)", the same hub-only section, not restated here. Keeping a working gate is not drift. +- **A working local commit/pre-commit gate is strongly suggested, not the repo's free choice to skip.** The *mechanism* is bounded by the toolchain the repo already keeps rather than by which languages its checks cover, and two canonical shapes carry it. Husky.Net runs from a .NET tool manifest declaring it, so a repo that keeps no such manifest uses the `pre-commit` framework instead. Any repo may also wire an equivalent hook of its own at `.husky/pre-commit`, enabled with `core.hooksPath` and sourcing nothing. Canonical shapes for both live in `catalog/snippets/` in the hub, not a repo-relative path since it is hub-local and not carried into every fleet repo. What that gate must cover, its per-clone enablement steps, and what its absence means for the audit, is `GOVERNANCE.md` "Running the Linters Locally (Known-Working Invocations)", the same hub-only section, not restated here. Keeping a working gate is not drift. ### Analyzer Diagnostics and Suppressions @@ -31,15 +31,13 @@ Each language defines a **clean-compile** verification: the combination of build ### Markdown and Spelling -These apply repo-wide, in every directory: Markdown lints clean via `markdownlint-cli2` against the shared config, spelling is US English via CSpell against the shared `cspell.json`, the CI spelling gate covers `README.md` and `HISTORY.md` only, `HISTORY.md` mirrors the README's opening, and "Markdown" is a proper noun in prose. The full rules are in the `comment-and-doc-style` Skill referenced above. - -The CI spelling gate is deliberately not all `**/*.md`: a repo carries many Markdown files full of technical terms, and gating every one of them would mean endlessly padding `cspell.json` just to keep CI green. `README.md` and `HISTORY.md` are the files every repo visitor sees, so those stay clean in CI; broad, live spell-checking across any file is the editor's Code Spell Checker extension's job instead, so a typo elsewhere still surfaces to whoever is editing it. +These apply repo-wide, in every directory: Markdown lints clean via `markdownlint-cli2` against the shared config, spelling is US English via CSpell against the shared `cspell.json`, the CI spelling gate covers `README.md` and `HISTORY.md` only, `HISTORY.md` mirrors the README's opening, and "Markdown" is a proper noun in prose. A repo excluding a subtree of its own that it does not treat as authored prose, a committed data archive, a vendored theme, or a hand-maintained record, puts a `.markdownlint-cli2.jsonc` carrying its own `ignores` beside that content rather than editing the shared root config, whose contents are fleet-fixed. Those `ignores` patterns resolve against the directory holding them rather than against the repo root, so a repo-root-relative entry there matches nothing and reports no error saying so, and excluding through the CI workflow's own negated Markdown glob input instead is a CI-only fix that leaves the same files flagged for anyone running the linter locally. The full rules are in the `comment-and-doc-style` Skill referenced above. ## .NET *This section applies only to the .NET side. A repo with no .NET projects still carries it (the file is carried whole) and ignores it.* -The style guide for any .NET projects in this repo: the zero-warnings build policy and its three-task clean-compile chain, central `Directory.Build.props`/`Directory.Packages.props` configuration, C# language and naming conventions, XML documentation, analyzer suppression scope, the library-versus-application logging split, async and error-handling patterns, xUnit v3 + AwesomeAssertions testing conventions, and AOT-compatible project configuration. +The style guide for any .NET projects in this repo: the zero-warnings build policy and its three-task clean-compile chain, central `Directory.Build.props`/`Directory.Packages.props` configuration, C# language and naming conventions, XML documentation, analyzer suppression scope, the library-versus-application logging split, async and error-handling patterns, xUnit v3 + AwesomeAssertions testing conventions, the runner declaration, package references and version floor that an MTP-based test project needs under `WORKFLOW.md` D1.6, with the local diagnostic for a run that reports no tests, and AOT-compatible project configuration. This is packaged as the `dotnet-codestyle` Skill at `.agents/skills/dotnet-codestyle/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 scope. Read the skill for the full rules, code examples, and mechanics. @@ -47,7 +45,7 @@ This is packaged as the `dotnet-codestyle` Skill at `.agents/skills/dotnet-codes *This section applies only to the Python side. A repo with no Python projects still carries it (the file is carried whole) and ignores it.* -The style guide for any Python project(s) in this repo: the build-versus-lint-only profile split, the uv/ruff/pyright/mypy/pytest toolchain, `src` layout, formatting and linting, comment and docstring conventions, type hints, naming, imports, patterns to avoid, test conventions, and versioning. +The style guide for any Python project(s) in this repo: the build-versus-lint-only profile split, the uv/ruff/pyright/mypy/pytest toolchain, `src` layout, formatting and linting, comment and docstring conventions, type hints, naming, imports, patterns to avoid, test conventions including the `pytest-cov` dependency and coverage selector a build-profile repo with tests owes under `WORKFLOW.md` D1.6, and versioning. This is packaged as the `python-codestyle` Skill at `.agents/skills/python-codestyle/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 scope. Read the skill for the full rules and the profile-adaptation guidance.