From 4df0ca7e19555e5d2ae65b48d9b21ac820eec59f Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 25 Sep 2026 19:17:58 -0700 Subject: [PATCH] Resync Instruction Set and Skills With the Fleet Hub Re-vendor the stale verbatim sections of AGENTS.md and GOVERNANCE.md, the .github/skills tree, and .markdownlint-cli2.jsonc from the hub. Merge the hub's intent changes into CODESTYLE.md, AUDIT.md, the AGENTS.md preamble, and .github/copilot-instructions.md, keeping this repo's own conventions. Drop GOVERNANCE.md "Running the Linters Locally", which the hub now hosts rather than carries, and re-point its references. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/copilot-instructions.md | 53 +-- .github/skills/agent-conduct/SKILL.md | 54 ++- .github/skills/audit-a-repo/SKILL.md | 2 +- .github/skills/backlog-burndown/SKILL.md | 47 ++- .../SKILL.md | 34 +- .../branch-protection-and-promotion.md | 12 +- .../references/release-publish-mechanics.md | 37 +- .../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/drive-pr/SKILL.md | 52 ++- .../SKILL.md | 12 +- .github/skills/local-strict-review/SKILL.md | 69 ++-- .github/skills/merge-and-release/SKILL.md | 50 +-- .github/skills/pr-review-conduct/SKILL.md | 156 +++++++-- .../python-codestyle/references/profiles.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 | 10 +- .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 | 21 +- .../references/architecture.md | 14 +- .../references/d-guarantees.md | 37 +- .markdownlint-cli2.jsonc | 10 +- AGENTS.md | 31 +- AUDIT.md | 2 +- CODESTYLE.md | 13 +- GOVERNANCE.md | 86 ++--- OPERATIONS.md | 2 +- 34 files changed, 1275 insertions(+), 407 deletions(-) rename .github/skills/{operational-vs-release-workflow => branching-and-release-model}/SKILL.md (88%) 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 (84%) 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 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2aa42ea2..4b5fa60f 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 @@ -62,5 +44,4 @@ Review effort is user-controlled. The automation observes `Lite`, `Balanced`, or ## 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 48f42ffa..e869ad11 100644 --- a/.github/skills/agent-conduct/SKILL.md +++ b/.github/skills/agent-conduct/SKILL.md @@ -1,14 +1,28 @@ --- 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 "Context and Delegation Discipline" section is the always-on layer, and this skill fires at the moments rather than duplicating it, 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, local-strict-review for the review passes a push owes, comment-and-doc-style for prose. The GOVERNANCE.md sections this skill surfaces keep the full rules, and the skill carries each of them whole as a generated include rather than as a summary. + 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 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. +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 @@ -25,7 +39,7 @@ The checks that separate work actually done from work that merely reports succes - **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 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, `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. `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` renders and is never a block on unrelated work. Recording a pass writes one tracked file, the engine's ledger, so where it lands relative to the commit is a real ordering rather than a preference. It is committed before the push, since a capture point that gates a push refuses tracked content 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. +- **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. @@ -46,40 +60,54 @@ The checks that separate work actually done from work that merely reports succes -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 change moved, their delegation shape, and how each is recorded are the `local-strict-review` skill's. +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. -- **The irreversible step (merge, publish, release, delete) 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. +- **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. -How to ask, how to reference what the question is about, and how to raise work that is blocked on the answer is `GOVERNANCE.md` "Communicating with the User", whole. +`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 (`[#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. -`GOVERNANCE.md` "Communicating with the User" keeps the full rules, and the `agent-conduct` Skill at `.agents/skills/agent-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries it whole as a generated include and surfaces it at its decision moment. +`GOVERNANCE.md` "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 -Where a lesson lands, and when it earns a mechanical hook, is `GOVERNANCE.md` "Durable Knowledge and Self-Improvement", whole. +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 moment. +`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 `fleet-conformance-check` Skill as the check. +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 diff --git a/.github/skills/audit-a-repo/SKILL.md b/.github/skills/audit-a-repo/SKILL.md index 070703f9..487dd8e5 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 diff --git a/.github/skills/backlog-burndown/SKILL.md b/.github/skills/backlog-burndown/SKILL.md index 50cf84b6..bb4c6119 100644 --- a/.github/skills/backlog-burndown/SKILL.md +++ b/.github/skills/backlog-burndown/SKILL.md @@ -4,16 +4,18 @@ 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 + 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. + 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 @@ -107,6 +109,25 @@ Rank on these, highest first where they conflict: - **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 @@ -125,7 +146,7 @@ for, and it binds harder than any throughput target. - **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 - `operational-vs-release-workflow`. A feature pull request merging into develop fires no + `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 @@ -251,7 +272,7 @@ Brief on `AGENTS.md` "Context and Delegation Discipline"'s subagent shape. ### Cleanup Is the Orchestrator's `repo-worktree`'s post-merge procedure returns the base clone to current develop before proving -the cleanup, and `operational-vs-release-workflow` states that requirement independently. Four workers doing +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 @@ -479,17 +500,17 @@ body when it lands rather than leaving the issue to be closed by hand. 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 is - a finished run. Report it as finished, with the questions put to the maintainer. + 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, and what the -next round would pick. +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 @@ -498,7 +519,7 @@ next round would pick. - 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: `operational-vs-release-workflow`. +- 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. diff --git a/.github/skills/operational-vs-release-workflow/SKILL.md b/.github/skills/branching-and-release-model/SKILL.md similarity index 88% rename from .github/skills/operational-vs-release-workflow/SKILL.md rename to .github/skills/branching-and-release-model/SKILL.md index 5639d9f8..c8bfac9f 100644 --- a/.github/skills/operational-vs-release-workflow/SKILL.md +++ b/.github/skills/branching-and-release-model/SKILL.md @@ -1,26 +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. This is the git half, and the - workflow-ci-contract skill keeps the YAML half, the D1-D9 contract and the job graph that - implements this policy. 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 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 6d934c0e..9ca22769 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 84% 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 3ca50760..fb6080ce 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 @@ -30,36 +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 `dorny/paths-filter` -entries are owned/per-target. The validation job is a call to the reusable validator, whose own -jobs a caller cannot address. 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 -(the hub's `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 diff --git a/.github/skills/carried-instruction-file-guard/SKILL.md b/.github/skills/carried-instruction-file-guard/SKILL.md index 47f88033..c6187228 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 2b6e8406..f16338bf 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 806ce4ed..7c6f9d6c 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 125bf0f8..9b66d1b3 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 9ee43bcc..1899ce81 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/drive-pr/SKILL.md b/.github/skills/drive-pr/SKILL.md index 9ea24f53..7322ec13 100644 --- a/.github/skills/drive-pr/SKILL.md +++ b/.github/skills/drive-pr/SKILL.md @@ -33,7 +33,8 @@ 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 @@ -56,7 +57,7 @@ promotion PR once the fix lands, is the early exit this skill exists to prevent. 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 (the complete delta)" + one, per branching-and-release-model's "Operational repositories (the complete delta)" section. ## What Invoking This Skill Authorizes @@ -78,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. @@ -141,9 +146,13 @@ maintainer when". -1. **Real, so fix it.** 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 +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 @@ -154,8 +163,12 @@ maintainer when". 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 @@ -164,11 +177,16 @@ maintainer when". 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 @@ -184,7 +202,7 @@ instance's outcome is context for the new one, never a standing answer to reuse - 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 af775184..42d3cda9 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 fa1c1a86..65373a9f 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. @@ -69,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. @@ -80,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. -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. +**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, 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. @@ -109,21 +112,19 @@ 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 --target --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 two mix, the engine's own section rules over the other tree's manifest and files. - -`` is the branch this work targets, resolved once as the pass above resolves it and passed to both commands explicitly. Left off it defaults to `develop`, so a branch based on `main` is measured from a fork point nobody read, and `record` stamps each pass with a merge-base against a branch the work never targeted. 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, `reports/canonical-review.json`, is a tracked file the commit has to carry, so writing it 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 `canonical_review.py report` renders 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 @@ -133,9 +134,9 @@ Each bullet is a rule down to its `Why:` line, which is rationale rather than ru - `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 `code-review` "Review the Change"'s own rule to omit preferences. +- **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. -- **A push allows two rounds of edits in answer to the passes it owes, one budget across both.** 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". +- **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. @@ -145,27 +146,25 @@ Each bullet is a rule down to its `Why:` line, which is rationale rather than ru - 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 3eb31c30..fcf432f3 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 b6f9f91a..6f7e3670 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,23 +40,61 @@ 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. The coverage this item + 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 (#1066). + 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 @@ -83,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 @@ -97,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 @@ -113,10 +183,11 @@ 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 this PR-hosted loop early.** Its pre-push @@ -129,9 +200,13 @@ After an authorized merge, run the `repo-worktree` post-merge cleanup procedure ## Every finding ends in one of five outcomes -1. **Real, so fix it.** 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 +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 @@ -142,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 @@ -152,11 +231,16 @@ 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 @@ -217,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/references/profiles.md b/.github/skills/python-codestyle/references/profiles.md index b7eedfb9..556c3d2a 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/repo-worktree/SKILL.md b/.github/skills/repo-worktree/SKILL.md index 4dda047f..3a5888e7 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 2bd17671..82588b86 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 00000000..d0504cbc --- /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 4a8aa6f8..e433685c 100644 --- a/.github/skills/skill-lifecycle/SKILL.md +++ b/.github/skills/skill-lifecycle/SKILL.md @@ -15,9 +15,9 @@ The agent most likely to get a skill wrong is the one editing a skill, and befor - **`.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. -- **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 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 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, so the whole-unit review pass `scripts/canonical_review.py` records for each of those units is owed again, which is the cost of a carrier reading generated text in the skill's own context. -- **`--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. A region it cannot render is a failure rather than a stale result, exit 2 rather than 1, because regenerating cannot repair it: 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, since it walks only the Markdown files of the skill directories, a region that opens inside another or never closes, a close marker with no region open, a cycle, a path outside the root, through a symlink, under a generated tree, or spelled otherwise than the tree spells it, a skill file or source that is not UTF-8, 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. -- **`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 @@ -26,13 +26,13 @@ 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 diff --git a/.github/skills/standup-a-repo/SKILL.md b/.github/skills/standup-a-repo/SKILL.md index 363d6bb3..61fc1e07 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 00000000..b6bf958c --- /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 eec4fcff..65458614 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 a02a47d6..c8a5e1a2 100644 --- a/.github/skills/workflow-ci-contract/SKILL.md +++ b/.github/skills/workflow-ci-contract/SKILL.md @@ -1,7 +1,20 @@ --- name: workflow-ci-contract description: >- - Governs the WORKFLOW.md CI/CD behavioral contract for every ptr727/ProjectTemplate fleet repo: the D1-D9 guarantees, the output seam by destination (a file on the GitHub release, 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 the operational-vs-release-workflow skill keeps the git half (branching, promotion, publish policy), so which branch a change targets, and which events the fleet allows to publish at all, go there while the job graph implementing that policy is here. 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: an upload a smoke run should have skipped succeeds instead of erroring, retention-days sits on an upload step no smoke run reaches, the aggregator's name is bound by a branch ruleset no build reads, and a PR changing only .github/workflows/ is deliberately not smoke-built. WORKFLOW.md keeps authority, and GOVERNANCE.md's Workflow YAML Conventions and Release Model sections win where those 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 @@ -29,3 +42,9 @@ description: >- ## After Any Workflow Edit 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 index 9f8e6736..b03c3355 100644 --- a/.github/skills/workflow-ci-contract/references/architecture.md +++ b/.github/skills/workflow-ci-contract/references/architecture.md @@ -33,12 +33,12 @@ Their CI is lint/validation only (editorconfig/EOL plus domain linters such as H ### 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 `build-` leaf tasks, whether this repo hosts them itself or reaches hub-hosted ones by pin. -- **What the repo curates** (by design, not a leak): the *list* of targets. This is **not** a byte-for-byte file carry. Adding or dropping a target edits the orchestrator's surface: the `enable_` inputs and the `build-` job + its `github-release` **and** `build-docker` `needs:` entries in the release task, **and** the `changes` paths-filter entry + output + the `smoke-build` enable-forward in the PR workflow, plus the separate `publish-` job for a package target. "Verbatim" applies to the `github-release` job and the version/publish-plan logic, except that job's own `needs:` list, and never to the release task's job list or the paths-filter. Subsetting is symmetric: the same surface you trim to drop a target you extend to add a new one (e.g. a `release-asset--library` producer needs a new `enable_library` input, a `build-library` job, its two `needs:` entries, and a `library` paths-filter entry, output, and `smoke-build` enable-forward). +- **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-id` handoff forks the release download and breaks the verbatim carry. +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 @@ -55,11 +55,11 @@ The diagram writes `BRANCH` and `TARGET` where the prose writes `` and ` ### 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 are branch-suffixed. +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, but **not always from `SemVer2`**: the PyPI version is built from `AssemblyFileVersion` (four-part `M.N.P.B`) 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. +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 @@ -67,7 +67,7 @@ When a workflow's inputs carry a cross-input or input-versus-derived-state invar ### 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. An intermediate consumed only within the same run may rely on the retention backstop alone. The run is **never** blanket-deleted (`.artifacts[].id`). See D5. +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 @@ -86,7 +86,7 @@ flowchart TD ### 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 first `plan` job (`publish-plan-task.yml`) decides once and every job gates on it. A run publishes on a **code-affecting bot push to `main`** (the App merges every Dependabot/codegen PR, so `github.actor` gates it, and a shared paths filter 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 re-pushes nothing (no-op republish). Docker re-pushes by design. +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 diff --git a/.github/skills/workflow-ci-contract/references/d-guarantees.md b/.github/skills/workflow-ci-contract/references/d-guarantees.md index c9e1f3e4..1a396623 100644 --- a/.github/skills/workflow-ci-contract/references/d-guarantees.md +++ b/.github/skills/workflow-ci-contract/references/d-guarantees.md @@ -10,41 +10,42 @@ The required behaviors, organized by domain. Each is a **MUST**, and its `Output ### D1 - PR Fast-Feedback (Smoke) -- **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. A filter written the other way round, as a negation of the paths that must not build, marks a docs-only change as a target change: it satisfies D1.4 and violates this item. *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 and the `dotnet test` or `pytest` path only where that tree is present, so a non-.NET repo calls the same one rather than replacing it. 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.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 nothing downstream of it can tell an absent report from an uploaded one. 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, and excluding intentionally-untested, non-shipped code (an example or benchmark project) from the denominator via `ignore`, which a repo may override where its quality bar requires a threshold. 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.* +- **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.* ### D2 - Input/State Validation at Entry - **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 skips with it unless that dependent opts out with `if: always()` and reads the result explicitly, the way the PR aggregator does. `github-release` carries `validate-release` in `needs:` and does **not** opt out, so a job-level skip there would couple the release to smoke through a second path on top of the `if:` it already carries. *Prevents: a non-default leg published as stable, a build-metadata false-positive, and the gate blocking every default-base promotion PR.* +- **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.* ### D3 - Versioning and Classification - **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`. The default-branch literal in the gate, the `prerelease` expression, and `version.json` MUST all name the repo's real default branch. -- **D3.3 Version floor + git height.** Output: `version.json` sets the major.minor floor. NBGV appends the git height as the patch, bumped only for a functional change by the maintainer. 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 `AssemblyFileVersion` (`M.N.P.B`) and appends `.dev0` on the `develop` branch only (a two-branch literal, not a generic N-branch rule). The develop `.dev0` build must remain `pip install --pre`-selectable and sort above the default release (NBGV git height in the release segment keeps develop ahead). *Prevents: a non-default leg published as a release, and a renamed/extra branch silently getting a plain 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.* ### D4 - Release / Publish -- **D4.1 Gated single-branch publish.** Output: PRs smoke-test and publish nothing. A **human merge never auto-publishes**. A first `plan` job (`publish-plan-task.yml`) decides once and every job gates on it: publish 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.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: nothing is re-pushed, because the release-create step is skipped when the tag exists, 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. 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.** Input: a real publish where one enabled build fails. Output: nothing publishes. `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.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.* ### D5 - Resource Cleanup -- **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. An intermediate consumed only within the same run MAY instead rely on the `retention-days: 1` backstop. *Prevents: transfer artifacts accumulating against the storage quota.* -- **D5.2 Gate the delete to the condition that made the artifact redundant.** Output: the delete runs exactly when the consumption it follows happened. 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.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.* @@ -53,15 +54,15 @@ The required behaviors, organized by domain. Each is a **MUST**, and its `Output ### D6 - Seam / Architecture Conformance - **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: branch-derived config reads `inputs.branch`, never `github.ref_name`. -- **D6.3 Branch-suffixed artifacts.** Output: artifact names are branch-suffixed 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_` input, the `build-` job and its `github-release` and `build-docker` `needs:` entries, 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. 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.* +- **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.* ### D7 - Concurrency, Permissions, Safety - **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 a string for the same reason 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.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.* ### D8 - Bots / Automation @@ -75,9 +76,9 @@ The required behaviors, organized by domain. Each is a **MUST**, and its `Output `GOVERNANCE.md` "Workflow YAML Conventions" names the tool D9.1 excepts and states the suffix rules D9.2 requires. -- **D9.1** Every action SHA-pinned with a version comment (sole exception: the documented lagging-tag tool). +- **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** Bash `run:` blocks start `set -Eeuo pipefail`. Multi-line `if:` uses `>-`. +- **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`. diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index ed90f846..e4331776 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/AGENTS.md b/AGENTS.md index 63c5e677..0d2512ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ This file is the entry point every coding agent reads first, and it holds only three things: the bootstrap that says where the canonical rules live and which procedure to follow for the state this repository is actually in, the rules for managing context and delegation, which apply to every task, and a map of where every other rule lives. The rule text itself is in [`GOVERNANCE.md`](./GOVERNANCE.md), one section per topic. Code style lives in [`CODESTYLE.md`](./CODESTYLE.md) (a General section plus per-language sections for .NET, Python, and Shell, the language sections packaged as the `dotnet-codestyle`, `python-codestyle`, and `shell-codestyle` Skills), the CI/CD workflow contract in [`WORKFLOW.md`](./WORKFLOW.md), the architecture, processing pipeline, and design patterns in [`ARCHITECTURE.md`](./ARCHITECTURE.md), and the local verification, runbooks, and tool usage in [`OPERATIONS.md`](./OPERATIONS.md). -Treat this file and `GOVERNANCE.md` as authoritative for cross-cutting rules, and do not restate their rules elsewhere. This project's own conventions and behavioral contracts live in the topical doc that owns them, [`ARCHITECTURE.md`](./ARCHITECTURE.md) for the processing pipeline and design patterns and [`CODESTYLE.md`](./CODESTYLE.md) for how the code is written, and **never** in [`.github/copilot-instructions.md`](./.github/copilot-instructions.md), because that file targets GitHub Copilot / VS Code specifically while the rest are agent-agnostic, so any rule a reviewer must honor has to live outside it to be provider-independent. Copilot review *mechanics* are owned by `.github/copilot-instructions.md`, and `GOVERNANCE.md` "PR Review Etiquette" delegates them there explicitly. High-level summaries in other docs (for example the README's Contributing section) are allowed when they link back here, without duplicating the rules themselves. +Treat this file and `GOVERNANCE.md` as authoritative for cross-cutting rules, and do not restate their rules elsewhere. A Skill that needs a rule's full text to work in isolation carries it as a generated include from the rule's home rather than as a copy, per the `skill-lifecycle` Skill, so the text cannot drift undetected. This project's own conventions and behavioral contracts live in the topical doc that owns them, [`ARCHITECTURE.md`](./ARCHITECTURE.md) for the processing pipeline and design patterns and [`CODESTYLE.md`](./CODESTYLE.md) for how the code is written, and **never** in [`.github/copilot-instructions.md`](./.github/copilot-instructions.md), because that file targets GitHub Copilot / VS Code specifically while the rest are agent-agnostic, so any rule a reviewer must honor has to live outside it to be provider-independent. A section of its own in this file is not the alternative. An undeclared section here is drift to reconcile rather than a local liberty. Copilot review *mechanics* are owned by `.github/copilot-instructions.md`, and `GOVERNANCE.md` "PR Review Etiquette" delegates them there explicitly. High-level summaries in other docs (for example the README's Contributing section) are allowed when they link back here, without duplicating the rules themselves. ## Fleet Bootstrap @@ -24,20 +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. - **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 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. +- **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. @@ -69,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 @@ -79,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) | @@ -93,10 +94,10 @@ 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 | +| 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` | @@ -106,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. 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. +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 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. +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/AUDIT.md b/AUDIT.md index a9ad1bf0..9b6dfff9 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -10,7 +10,7 @@ This is a release-model repo: the self-audit covers the `main` and `develop` rul ## General Settings and Rulesets -Fetch the hub and check out `main`. Run `repo-config/configure.sh check ptr727/PlexCleaner release` from that checkout. The command checks the shared settings, the two state-dependent settings (`has_discussions` follows visibility, `default_branch` is `main`), Dependabot security features, and both the `develop` and `main` rulesets against the hub payloads, and preserves and reports `bypass_actors` without asserting them, since who may bypass a ruleset is a human decision no payload declares. +Fetch the hub and check out `main`. Run `repo-config/configure.sh check ptr727/PlexCleaner release` from that checkout. The command checks the shared settings, the two state-dependent settings (`has_discussions` follows visibility, `default_branch` is `main`), Dependabot security features, the fleet label set, the fleet project link, any deployment environments this repo's registry entry declares, and both the `develop` and `main` rulesets against the hub payloads, and preserves and reports `bypass_actors` without asserting them, since who may bypass a ruleset is a human decision no payload declares. The result must be exactly two rulesets named `develop` and `main`. A missing ruleset or a divergent payload is a **defect**, and a duplicate or stray ruleset is a **drift finding**. diff --git a/CODESTYLE.md b/CODESTYLE.md index b59f3f74..44037fd2 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -16,9 +16,9 @@ Use each tool's official casing in task labels, docs, and prose, per the `commen Each language defines a **clean-compile** verification: the combination of build, formatter, linter, and code-analysis tools that must report clean before a commit. It is exposed as one or more **named** VS Code tasks (or, where a language ships no tasks, documented commands), and those definitions are the same across the fleet. The concrete names live in each language section below. -- **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"][governance-running-the-linters-locally]. +- **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/`. What that gate must cover, and what its absence means for the audit, is [GOVERNANCE.md "Running the Linters Locally"][governance-running-the-linters-locally], 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,13 +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. +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. @@ -56,7 +56,7 @@ The conventions below are this repo's own, beyond the carried rules. *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. @@ -66,12 +66,11 @@ The only Python in this repo is the stdlib-only tooling under `RegressionTests/` ## Shell -Bash, and only where a program cannot be Python: a bootstrap that installs the interpreter cannot be written in it, and a host tool that must run before a development toolchain exists cannot depend on one. Everything else is Python, with a test under the scripts tree's `tests/` directory. The mandatory `set -Eeuo pipefail` header, the pipefail-versus-early-reader pitfall, self-locating scripts, the `shellcheck`-plus-`shfmt` clean-compile, and the why-not-what comment rule are packaged as the `shell-codestyle` Skill at `.agents/skills/shell-codestyle/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. Run the clean-compile check itself per [GOVERNANCE.md "Running the Linters Locally"][governance-running-the-linters-locally], not by probing `command -v shellcheck`. +Bash, and only where a program cannot be Python: a bootstrap that installs the interpreter cannot be written in it, and a host tool that must run before a development toolchain exists cannot depend on Python either. Everything else is Python, with a test under its own scripts tree's `tests/` directory. The mandatory `set -Eeuo pipefail` header, the pipefail-versus-early-reader pitfall, self-locating scripts, the `shellcheck`-plus-`shfmt` clean-compile, and the why-not-what comment rule are packaged as the `shell-codestyle` Skill at `.agents/skills/shell-codestyle/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. Run the clean-compile check itself per `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, not by probing `command -v shellcheck`. [governance]: ./GOVERNANCE.md -[governance-running-the-linters-locally]: ./GOVERNANCE.md#running-the-linters-locally-known-working-invocations [governance-verification-discipline]: ./GOVERNANCE.md#verification-discipline [readme]: ./README.md [root]: ./.editorconfig diff --git a/GOVERNANCE.md b/GOVERNANCE.md index bcad7fcb..8a8f41d0 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -17,9 +17,12 @@ The specific rules in this file implement a few governing principles. Read these - **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 moment. +`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. @@ -76,8 +79,8 @@ weekly schedule (Docker), and versioning is semantic and maintainer-controlled ( 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, the recovery routes for a package push that fails after the release is @@ -92,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. @@ -123,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. @@ -143,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. @@ -151,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. @@ -174,7 +181,7 @@ The checks that separate work actually done from work that merely reports succes - **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 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, `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. `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` renders and is never a block on unrelated work. Recording a pass writes one tracked file, the engine's ledger, so where it lands relative to the commit is a real ordering rather than a preference. It is committed before the push, since a capture point that gates a push refuses tracked content 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. +- **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. @@ -195,7 +202,7 @@ The checks that separate work actually done from work that merely reports succes ## 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. @@ -203,11 +210,14 @@ The provider-specific mechanics this contract needs to actually drive GitHub Cop ## 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. -`GOVERNANCE.md` "Communicating with the User" keeps the full rules, and the `agent-conduct` Skill at `.agents/skills/agent-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries it whole as a generated include and surfaces it at its decision moment. +`GOVERNANCE.md` "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 @@ -215,12 +225,12 @@ These conventions bind every workflow. Several of them [`WORKFLOW.md`](./WORKFLO An overlap with `WORKFLOW.md` resolves **by subject**, never by blanket precedence. This section keeps the full style rules and wins on them, stating each in more detail than the guarantee that carries it, while `WORKFLOW.md` wins on the architecture, the contract, and the test methodology. `WORKFLOW.md` section 2 points at this section rather than restating it. Throughout, a job is named by its id and a step by its `name:`. The `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, surfaces it. -- **Action pinning**: pin **every** action, first-party (`actions/*`) and third-party alike, to a commit SHA with a trailing `# vX.Y.Z` comment, so Renovate / Dependabot can still bump it but a tag swap can't change the executed code. This binds a `uses:` wherever it appears, in a workflow and in a composite action under `.github/actions/**` alike. 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). +- **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. -- **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. 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. +- **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. @@ -230,44 +240,6 @@ An overlap with `WORKFLOW.md` resolves **by subject**, never by blanket preceden - **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). -## Running the Linters Locally (Known-Working Invocations) - -CI runs the full lint set, but run the linters locally before pushing to catch issues early, so an agent must know how to invoke them. Their non-Docker install paths (curl-pipe installers, global npm) are frequently blocked in sandboxes or fail on WSL, so **prefer the Docker invocations below, the known-working path that needs no local toolchain.** These tools auto-discover their targets from the working directory. None of markdownlint, cspell, actionlint, editorconfig-checker, shellcheck, shfmt, or PSScriptAnalyzer is installed natively on a fleet host, by decision, so a `command -v ` miss means exactly that and not that the check is unavailable here: read this section for the working invocation before reporting a check as skipped or unavailable. - -**Each surface runs the lint with the tool that fits it, all from the same config files** (`.markdownlint-cli2.jsonc`, `cspell.json`, `.editorconfig`): - -- **CI (authoritative)** runs **markdownlint-cli2**, **cspell**, and **actionlint** as pinned action wrappers (Dependabot bumps them), plus **editorconfig-checker** via Docker `:latest` (its action only installs the CLI, so the Docker one-liner is what actually runs the check), **shellcheck** and **shfmt** the same way for a repo that carries a shell script, and, **for a repo that carries `.ps1` files**, **PSScriptAnalyzer** the same way (none of the three has an action). markdownlint covers all `**/*.md`, and **cspell is scoped to `README.md` + `HISTORY.md`** (see [CODESTYLE.md](./CODESTYLE.md) "Markdown and Spelling" for why), matching the cspell one-liner below. A "shell script" here is a tracked `.sh` file, plus a tracked, extension-less file whose shebang names bash or sh, the shape a script meant to run as a bare command takes: a bare `*.sh` glob misses that second case, so both discovery paths run before either tool does. This whole block is the hub's `validate-task.yml` reusable workflow, so a fleet repo reaches it rather than carrying a copy of these steps. -- **A working local hook is strongly suggested fleet-wide, and its absence is a measured audit finding, not an invisible gap.** `spec/project-types.json`'s `parity.hooks` check reads this section for its rationale, judged by hand during an `AUDIT.md` run like every sibling check in its dimension, never mechanized by `spec/audit.py`: a repo with no local hook mechanism wired at all is a `linter-parity` defect, the same severity a missing markdownlint config already gets, while a repo mid-convergence (below) stays operational. CI remains the authoritative run regardless. Two catalog snippets carry the canonical shape, `catalog/snippets/husky/` (Husky.Net, for .NET or any project including Python) and `catalog/snippets/pre-commit/` (the Python `pre-commit` framework, for a repo with no `.husky/` tree), each carrying a copy of `catalog/snippets/hub-fetch-run.py` alongside it. -- **The hook** runs **language formatting** and the **diff-scoped doc gates**, never Docker, so it stays fast. The formatting half is whatever the repo's own language needs, CSharpier and `dotnet format` for .NET or ruff and the repo's type checker for Python, via native tooling. A repo adds each half once its tree passes that half, since a gate that fails on the corpus it guards blocks every commit from the moment it lands, so a hook running one half is a repo mid-convergence rather than a repo out of conformance. The doc half runs each gate at the scope that fits it. The prose gate is scoped to the working tree diff against `HEAD` rather than swept over the tree, which is the difference between about 2.2 seconds and about 0.13 and is what makes it affordable in a hook at all. That scope is the working tree rather than the staged index, so a partially staged file is judged on all of its edits, not only the staged ones, and CI re-checks the whole tree regardless, which is what makes that scope affordable in a hook. A whole-repo check belongs there too when it is already fast and takes no file list, which the line-ending consistency check is, so scope is a property of the gate rather than a rule the hook applies to all of them. `repo_gate.py --check sha-pin` stays out of the hook regardless, since it resolves a same-owner pin against the GitHub API. -- **The doc gates reach a downstream repo by fetching `.github/actions/prose-gate/prose_lint.py` and `.github/actions/repo-gate/repo_gate.py` fresh from `ptr727/ProjectTemplate`'s `main` branch, via `hub-fetch-run.py`, never vendored and never pinned.** Pinning anything Dependabot does not maintain goes stale by construction, and CI (this repo's own, and the hub's) is the backstop that catches a change landing broken on `main` before a locally fetched copy does real damage. These are the only network calls the doc gates make, one per fetched script. A Python repo's `uvx`-run ruff and type checker can also reach the network, to resolve `@latest` on a cache miss or refresh, the same category of dependency as the Docker pulls the VS Code Lint tasks already do routinely, not a new one. A fetch failure fails the commit, and it never silently skips the gate. The **hub's own** `.husky/pre-commit` is the one exception, staying local and offline, since it already carries `scripts/prose_lint.py` and `scripts/repo_gate.py` directly and has no hub to reach. A repo enables its hook per clone with `git config core.hooksPath .husky` or (`uv tool install pre-commit` once, then) `pre-commit install`. The Husky.Net snippet needs one more step per clone, `dotnet tool restore` then `dotnet husky install`, which generates `.husky/_/husky.sh`, the file the hook sources. CI remains the authoritative run either way. -- **The VS Code Lint tasks** run the full doc-lint set via Docker `:latest` on demand, the local surface for Markdown, spelling, workflow, and EditorConfig checks, plus `Lint: Prose` and `Lint: EOL`, the same two hook gates in whole-repo mode rather than diff-scoped, for on-demand full-tree validation. - -The Docker invocations below run the same tools and configs as the VS Code tasks. Their headless form separates the image pull and minimizes repository exposure for an agent executor. - -**Restricted executors keep tool state in a task-specific writable temporary directory.** Set each tool's own cache variable, such as `UV_CACHE_DIR` and `RUFF_CACHE_DIR`, instead of changing `HOME` or an agent configuration directory. A sandbox denial is not a lint result. Preserve the denial, then rerun the required command through the executor's scoped approval mechanism. Network approval covers any required fetch, including an image or package download. Host approval covers access to the Docker socket. Repository-exposure approval covers letting third-party image code read the checkout, even through a read-only mount. Persist approval only when the executor constrains the read-only mount, disabled networking, and resolved digest together. Never allow an unconstrained `docker run` prefix. PSScriptAnalyzer's separate module-install phase gets network approval without any repository mount. Report the approved rerun as the evidence. - -Agent-specific authorization stays separate from the executor-neutral contract above: - -- **Codex:** execution rules match exact argument prefixes, so they cannot safely cover changing worktree paths and digests. Smart Approvals can therefore request repository-exposure approval per task. The no-prompt alternative combines `sandbox_mode = "danger-full-access"` with `approval_policy = "never"`. Use that pair only when an external sandbox contains the Codex process. It removes protection from every command rather than only lint. - -Run the hub-hosted wrapper from the repository it checks: - -```sh -python3 /path/to/ProjectTemplate/scripts/docker_lint.py --root "$PWD" -``` - -The wrapper discovers tracked and unignored targets before it pulls applicable images. It reports a zero-target skip without pulling or mounting the repository. It pulls each applicable image in a distinct pull phase, then resolves the pulled repository digest. A digest prevents the tag from changing between the pull and execution. It does not make third-party code trusted. - -After all pulls, the wrapper reports that repository mounts are about to begin. Each execution uses the resolved digest, disabled networking, and a read-only checkout mount. PSScriptAnalyzer installs its pinned module in a separate container without the checkout mount. File-argument linters receive each tracked path as a distinct argument, split across bounded batches before host command-line limits become relevant. - -Every primary Docker command has a five-minute timeout by default. Use `--timeout` to select another positive bound. The wrapper emits a start and completion line for each primary command. Timeout cleanup has a separate maximum of 30 seconds and emits its result through the failed lint step. The wrapper reports the checked-file count for every linter, including tools that produce no success output. Timeout, container failure, zero-target execution, and successful quiet completion have distinct result lines. The wrapper names each lint container and removes it after a timeout. - -Use repeated `--linter` options for a subset. The supported names are `editorconfig-checker`, `actionlint`, `markdownlint`, `cspell`, `shellcheck`, `shfmt`, and `PSScriptAnalyzer`. editorconfig-checker reads the mounted tree. actionlint reads eligible workflows and includes shellcheck for `run:` blocks. markdownlint reads tracked and unignored Markdown files. CSpell reads `README.md` and `HISTORY.md` only. shellcheck, shfmt, and PSScriptAnalyzer run only when matching scripts are tracked or unignored. shellcheck and shfmt share one target list, a tracked `.sh` file plus a tracked, extension-less file whose shebang names bash or sh. - -In a configured editor the `DavidAnson.vscode-markdownlint` extension is enough for Markdown. Use the wrapper for a headless run or before pushing. - -When pulling a public image fails on a Docker-Desktop/WSL credential-helper error (`docker-credential-desktop.exe: exec format error`), retry with an empty Docker config: `DOCKER_CONFIG=$(mktemp -d) docker run ...` after writing `{}` to `$DOCKER_CONFIG/config.json`. - ## Supported Development Platforms - **Cross-platform by default: Windows + macOS + Linux.** Linux runs natively (a Linux desktop, or SSH/remote into a Linux host), through a devcontainer on Windows or macOS, or through WSL2 on Windows, where the devcontainer and WSL routes carry their own nuances (mounts, path translation, SSH-agent forwarding) but deliver the same toolchain. Editing is cross-platform through the GUI regardless of where code runs. Assume this default. @@ -315,4 +287,4 @@ Every repo's GitHub repository details (the About panel) follow a fixed conventi - [`.github/skills/`](./.github/skills/): the fleet Skills, carried whole from the hub and never edited here. - [`.github/copilot-instructions.md`](./.github/copilot-instructions.md): the Copilot review runbook. -After editing a doc, run the linters (see "Running the Linters Locally") before commit. The Comments, Character Set, and Line Endings rules above are the frequent regressions, so hold them exactly. +After editing a doc, run the doc linters through the hub-hosted wrapper, per "Hub-Hosted Tooling" and [`OPERATIONS.md`](./OPERATIONS.md) "Tool Usage", before commit. The Comments, Character Set, and Line Endings rules above are the frequent regressions, so hold them exactly. diff --git a/OPERATIONS.md b/OPERATIONS.md index 44afe375..287fb7e2 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -43,7 +43,7 @@ Application logging is Serilog, to the console and, with `--logfile`, to a file, The application orchestrates FFmpeg, HandBrake, MkvToolNix, MediaInfo, and 7-Zip through CLI wrappers, and the Docker image bundles tested versions of all of them, so the image is the reference environment for reproducing a media-processing result. On Windows `checkfornewtools` downloads newer tool versions and `gettoolinfo` prints the installed ones, and the [README](./README.md) covers each platform. The doc linters run through the hub-hosted wrapper, whose invocations and rationale are -[GOVERNANCE.md "Running the Linters Locally"](./GOVERNANCE.md#running-the-linters-locally-known-working-invocations), +[the hub's GOVERNANCE.md "Running the Linters Locally"](https://github.com/ptr727/ProjectTemplate/blob/main/GOVERNANCE.md#running-the-linters-locally-known-working-invocations), not restated here. The `.NET Tool Update` and `.NET Outdated Upgrade` tasks refresh the local .NET tools and prompt through dependency updates, and `dotnet husky install` reinstalls the commit hook after a fresh clone.