From ad5b8152d59eb743cb23aa0a74560319c18b58a2 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 15 Aug 2026 20:27:57 -0700 Subject: [PATCH 1/3] Flip the Fleet Line-Ending Default from CRLF to LF GOVERNANCE.md#line-endings and the comment-and-doc-style Skill's references/line-endings.md now state LF as the [*] .editorconfig default, with CRLF pinned only for *.bat/*.cmd, the one type Windows itself requires it for. .gitattributes is unchanged: its existing execution-sensitive LF pins (*.sh, Dockerfiles, uv.lock, the shebang-executed .py by-path list) stay as git-level enforcement, now redundant with the new [*] default but kept per the maintainer's direction. The redundant per-type LF overrides that used to sit below the old CRLF default in .editorconfig are dropped, since the default now already gives every one of them LF. Includes a one-time renormalization of every tracked CRLF file in the hub to LF (160 files, verified content-identical via git diff --ignore-cr-at-eol), and updates every doc/spec/script that described or depended on the old CRLF-default assumption: GOVERNANCE.md, README.md, OPERATIONS.md, STANDUP.md, docs/host-setup.md, TODO.md (drops the now-superseded "Default .py to LF" cluster), spec/project-types.json, spec/validate.py, registry/repos.schema.json, the operational-vs-release-workflow and python-codestyle and skill-lifecycle and git-commit-conventions Skills, the check-upstream-version-task.yml and husky README catalog snippets, and the CRLF report-writers in spec/workflow_reuse.py and spec/fidelity_honesty.py. scripts/build_dist.py's plugin-manifest and digest-stamp writers now emit LF explicitly instead of CRLF, with scripts/tests/test_build_dist.py updated to match (the old CRLF assertion discriminated a missing explicit newline argument on Linux CI; that discrimination is not available in the LF direction, since LF is also Linux's platform default, so the test now asserts the output shape only, documented in its docstring). .claude-plugin/fleet-skills/ regenerated from the updated .agents/skills/ source via build_dist.py. Verified: editorconfig-checker, markdownlint-cli2, cspell, actionlint, repo_gate.py, prose_lint.py --diff, spec/validate.py, spec/audit.py --selftest, spec/workflow_reuse.py --selftest, and the scripts/tests/ + host-setup/agent-safety/test_install.py suites all pass. --- .agents/skills/README.md | 14 +- .agents/skills/agent-conduct/SKILL.md | 88 +- .agents/skills/audit-a-repo/SKILL.md | 74 +- .../carried-instruction-file-guard/SKILL.md | 62 +- .agents/skills/comment-and-doc-style/SKILL.md | 462 ++--- .../references/carried-doc-references.md | 122 +- .../references/line-endings.md | 267 +-- .../references/markdown-links.md | 128 +- .../copilot-instructions-keeper/SKILL.md | 190 +- .agents/skills/dotnet-codestyle/SKILL.md | 718 +++---- .../skills/fleet-conformance-check/SKILL.md | 148 +- .../skills/git-commit-conventions/SKILL.md | 369 ++-- .../operational-vs-release-workflow/SKILL.md | 310 +-- .../branch-protection-and-promotion.md | 227 +-- .../references/release-publish-mechanics.md | 248 +-- .agents/skills/pr-review-conduct/SKILL.md | 292 +-- .agents/skills/python-codestyle/SKILL.md | 573 +++--- .agents/skills/repo-worktree/SKILL.md | 268 +-- .agents/skills/resync-a-repo/SKILL.md | 160 +- .agents/skills/shell-codestyle/SKILL.md | 110 +- .agents/skills/skill-lifecycle/SKILL.md | 98 +- .agents/skills/standup-a-repo/SKILL.md | 198 +- .../upstream-contribution-workflow/SKILL.md | 160 +- .agents/skills/workflow-ci-contract/SKILL.md | 94 +- .../references/d-guarantees.md | 140 +- .../references/test-methodology.md | 54 +- .../fleet-skills/.claude-plugin/plugin.json | 56 +- .claude-plugin/fleet-skills/.source-digest | 2 +- .../skills/agent-conduct/SKILL.md | 88 +- .../fleet-skills/skills/audit-a-repo/SKILL.md | 74 +- .../carried-instruction-file-guard/SKILL.md | 62 +- .../skills/comment-and-doc-style/SKILL.md | 462 ++--- .../references/carried-doc-references.md | 122 +- .../references/line-endings.md | 267 +-- .../references/markdown-links.md | 128 +- .../copilot-instructions-keeper/SKILL.md | 190 +- .../skills/dotnet-codestyle/SKILL.md | 718 +++---- .../skills/fleet-conformance-check/SKILL.md | 148 +- .../skills/git-commit-conventions/SKILL.md | 369 ++-- .../operational-vs-release-workflow/SKILL.md | 310 +-- .../branch-protection-and-promotion.md | 227 +-- .../references/release-publish-mechanics.md | 248 +-- .../skills/pr-review-conduct/SKILL.md | 292 +-- .../skills/python-codestyle/SKILL.md | 573 +++--- .../skills/repo-worktree/SKILL.md | 268 +-- .../skills/resync-a-repo/SKILL.md | 160 +- .../skills/shell-codestyle/SKILL.md | 110 +- .../skills/skill-lifecycle/SKILL.md | 98 +- .../skills/standup-a-repo/SKILL.md | 198 +- .../upstream-contribution-workflow/SKILL.md | 160 +- .../skills/workflow-ci-contract/SKILL.md | 94 +- .../references/d-guarantees.md | 140 +- .../references/test-methodology.md | 54 +- .claude-plugin/marketplace.json | 28 +- .editorconfig | 475 ++--- .editorconfig-checker.json | 20 +- .gitattributes | 104 +- .github/copilot-instructions.md | 726 +++---- .github/dependabot.yml | 56 +- .gitignore | 60 +- .markdownlint-cli2.jsonc | 36 +- AGENTS.md | 204 +- AUDIT.md | 456 ++--- CODESTYLE.md | 124 +- GOVERNANCE.md | 630 +++--- HISTORY.md | 20 +- LICENSE | 42 +- OPERATIONS.md | 250 +-- PSScriptAnalyzerSettings.psd1 | 26 +- ProjectTemplate.code-workspace | 52 +- README.md | 774 +++---- RESYNC.md | 268 +-- STANDUP.md | 505 +++-- TODO.md | 1361 +++++++------ WORKFLOW.md | 604 +++--- catalog/README.md | 18 +- catalog/snippets/configs/codecov.yml | 36 +- catalog/snippets/configs/dependabot.yml | 150 +- catalog/snippets/configs/docker-hub-readme.md | 54 +- .../snippets/configs/vscode-tasks-python.json | 368 ++-- catalog/snippets/configs/vscode-tasks.json | 368 ++-- .../devcontainer/dotnet/devcontainer.json | 122 +- .../devcontainer/python/devcontainer.json | 122 +- catalog/snippets/husky/README.md | 14 +- catalog/snippets/vscode/README.md | 64 +- catalog/snippets/vscode/base.jsonc | 56 +- catalog/snippets/vscode/docker.jsonc | 18 +- catalog/snippets/vscode/dotnet.jsonc | 30 +- catalog/snippets/vscode/python.jsonc | 36 +- catalog/snippets/workflows/README.md | 50 +- .../workflows/check-upstream-version-task.yml | 4 +- cspell.json | 296 +-- docs/content-import.md | 102 +- docs/devcontainer.md | 246 +-- docs/fleet-map.md | 770 +++---- docs/host-setup.md | 653 +++--- docs/peer-messaging.md | 64 +- docs/repo-config-carry.md | 122 +- docs/reusable-workflows.md | 512 ++--- docs/ssh-signing.md | 254 +-- docs/token-cost.md | 208 +- host-setup/README.md | 188 +- .../agent-safety/.markdownlint-cli2.jsonc | 18 +- host-setup/agent-safety/README.md | 206 +- host-setup/agent-safety/claude-md-fleet.md | 24 +- host-setup/agent-safety/claude-md-safety.md | 60 +- host-setup/agent-safety/install.ps1 | 62 +- host-setup/bootstrap.ps1 | 874 ++++---- host-setup/linux/README.md | 322 +-- host-setup/windows/README.md | 294 +-- host-setup/windows/install-skills.ps1 | 184 +- host-setup/windows/install-tools.ps1 | 1792 ++++++++--------- host-setup/windows/setup-github.ps1 | 1224 +++++------ host-setup/windows/setup-wsl.ps1 | 700 +++---- host-setup/windows/upgrade-host.ps1 | 684 +++---- host-tools.json | 10 +- pyproject.toml | 36 +- registry/repos.json | 650 +++--- registry/repos.schema.json | 160 +- repo-config/README.md | 78 +- repo-config/develop.json | 122 +- repo-config/main.json | 116 +- repo-config/operational/develop.json | 48 +- repo-config/settings.json | 20 +- reports/_template.md | 82 +- reports/aiopurpleair/audit.md | 196 +- reports/blog/audit.md | 310 +-- reports/conformance-matrix.md | 86 +- reports/divergences.md | 136 +- reports/esphome-nonroot/audit.md | 192 +- reports/homeassistant-purpleair/audit.md | 200 +- reports/homeautomation-config/audit.md | 114 +- reports/languagetags/audit.md | 176 +- reports/photocleaner/audit.md | 114 +- reports/plexcleaner/audit.md | 146 +- reports/utilities/audit.md | 154 +- reports/vscode-server-dotnetcore/audit.md | 194 +- reports/workflow-reuse.md | 322 +-- scripts/README.md | 456 ++--- scripts/build_dist.py | 12 +- scripts/skills_install.ps1 | 94 +- scripts/tests/test_bootstrap.py | 17 +- scripts/tests/test_build_dist.py | 11 +- spec/audit.py | 2 +- spec/divergences.json | 32 +- spec/divergences.schema.json | 80 +- spec/fidelity-model.md | 94 +- spec/fidelity_honesty.py | 4 +- spec/files.json | 86 +- spec/files.schema.json | 116 +- spec/host-tools-local.schema.json | 110 +- spec/host-tools.json | 242 +-- spec/host-tools.schema.json | 198 +- spec/project-types.json | 390 ++-- spec/project-types.schema.json | 76 +- spec/readme-sections.json | 172 +- spec/readme-sections.schema.json | 292 +-- spec/readme-structure.md | 216 +- spec/scope-model.md | 92 +- spec/secrets.json | 128 +- spec/secrets.schema.json | 130 +- spec/section-model.md | 206 +- spec/third-party-tools.json | 92 +- spec/third-party-tools.schema.json | 52 +- spec/type-model.md | 158 +- spec/validate.py | 2 +- spec/workflow_reuse.py | 4 +- version.json | 20 +- 168 files changed, 18281 insertions(+), 18347 deletions(-) diff --git a/.agents/skills/README.md b/.agents/skills/README.md index c4c8692c..bdcc3096 100644 --- a/.agents/skills/README.md +++ b/.agents/skills/README.md @@ -1,7 +1,7 @@ -# Fleet Skills - -Canonical source for the fleet's Claude Code / opencode / Codex Skills, one directory per skill: `/SKILL.md` plus optional `scripts/` and `references/`. This is the only place a skill's content is hand-authored. Everything else derived from it is generated, never hand-edited. - -Codex and opencode read this directory directly (`.agents/skills//SKILL.md`), no install step required, walking from a downstream repo's working directory up to its own repository root. Claude Code does not scan this path. `scripts/build_dist.py` generates a Claude-plugin-compatible copy at `.claude-plugin/fleet-skills/`, published through `.claude-plugin/marketplace.json`. - -See `AGENTS.md` for how a repo depends on these skills and `scripts/README.md` for `build_dist.py` and the installer. The procedure for creating, changing, or retiring a skill is the `skill-lifecycle` skill (`skill-lifecycle/SKILL.md` here), which this README defers to. +# Fleet Skills + +Canonical source for the fleet's Claude Code / opencode / Codex Skills, one directory per skill: `/SKILL.md` plus optional `scripts/` and `references/`. This is the only place a skill's content is hand-authored. Everything else derived from it is generated, never hand-edited. + +Codex and opencode read this directory directly (`.agents/skills//SKILL.md`), no install step required, walking from a downstream repo's working directory up to its own repository root. Claude Code does not scan this path. `scripts/build_dist.py` generates a Claude-plugin-compatible copy at `.claude-plugin/fleet-skills/`, published through `.claude-plugin/marketplace.json`. + +See `AGENTS.md` for how a repo depends on these skills and `scripts/README.md` for `build_dist.py` and the installer. The procedure for creating, changing, or retiring a skill is the `skill-lifecycle` skill (`skill-lifecycle/SKILL.md` here), which this README defers to. diff --git a/.agents/skills/agent-conduct/SKILL.md b/.agents/skills/agent-conduct/SKILL.md index a1088902..7d189ca9 100644 --- a/.agents/skills/agent-conduct/SKILL.md +++ b/.agents/skills/agent-conduct/SKILL.md @@ -1,44 +1,44 @@ ---- -name: agent-conduct -description: >- - Surfaces the ptr727/ProjectTemplate fleet's conduct rules at the three decision moments they are violated: about to claim work is done, verified, green, or fixed, about to proceed on an assumption the user could cheaply confirm, and a failure or review finding just surfaced a durable lesson. Use this whenever about to report success or completion of any task, whenever about to pick a default, guess an intent, or resolve an ambiguity without asking, whenever work is blocked on a decision or authorization only the user can give, and whenever an incident, a wrong answer, or a repeated correction just taught something a future session must honor. Deliberately narrow: the carried AGENTS.md sections are the always-on layer, and this skill fires at the moments rather than duplicating them, so do not load it as general background. Where a sibling skill owns the moment, it wins: git-commit-conventions for committing, pr-review-conduct for review and merge claims, comment-and-doc-style for prose. The GOVERNANCE.md sections this skill summarizes keep the full rules. ---- - -# Agent Conduct - -## Why This Exists - -The fleet's conduct rules (verification before claiming done, asking instead of assuming, recording lessons) lived only in doc sections nothing surfaced at the moment of violation, so they were honored by whoever happened to have read them recently. This skill is the decision-moment surface. The full rules stay in `GOVERNANCE.md` ("Verification Discipline", "Communicating with the User", "Durable Knowledge and Self-Improvement"), which keeps authority, and in the carried `AGENTS.md` "Context and Delegation Discipline" section, which is the always-on layer. - -## Before Claiming Done - -Read `GOVERNANCE.md` "Verification Discipline" before reporting success on anything non-trivial. Its unifying property: every failure it lists is green. The checks that bind here: - -- **A green check is not evidence the work happened.** A skipped job and a passing job are indistinguishable in an aggregated required check, so confirm from the log that the job ran and produced what it promises. -- **Locate every check the change owes before running any**, from what the repository declares (`OPERATIONS.md` "Local Verification" beside the workflows), not from what the pipeline happens to run, since part of a contract is routinely unreachable from a runner and green is then the precise signal it was skipped. -- **Run the repo's whole lint gate before every push**, not the parts that look relevant, because the tool most likely to catch a change is often the one it seems least about. -- **A launched process is not a result.** Report the output the wait produced, and where it produced none, that absence is the report. Never name an external cause the record does not carry. -- **A local clone is not the branch it names.** Fetch immediately before reading, or read the live ref, and name the ref and commit in any finding a local read produced. -- **A "does not exist" claim names the branch it was checked against.** A worktree's default branch is not necessarily the one the content lives on: in-flight content on a `release`-model repo lands on `develop` before `main`, per `GOVERNANCE.md` "Branching Model," so check that branch before reporting anything absent repo-wide. -- **A test asserts the mechanism it names, and a gate has to be watched failing.** A case that passes for an incidental reason is worse than no case, because it is later cited as evidence. -- **Platform-specific code is verified only on the platform it runs on.** Reasoning about PowerShell, macOS, or WSL-specific behavior from a different host is not verification, however closely it matches an already-tested equivalent elsewhere. State an untested structural match as exactly that, never in the words used for a tested fact, and when no agent in the loop has access to the target platform, say so and defer or ship it labeled unverified. - -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`. - -## Before Assuming - -- **Ask when the user can cheaply confirm.** An assumption that saves one question and is wrong costs the rework plus the trust, so a genuine ambiguity in intent, scope, or authorization is raised, not resolved by picking the likelier reading. Rules that already answer the question (the committed instruction set) are not ambiguity, so read them first rather than asking what they state. -- **Raise blocked work as a direct interactive prompt** at the point the work stops, per `GOVERNANCE.md` "Communicating with the User": the blocked item is the message, the options offered are the actions themselves, and a handoff buried in a summary paragraph is a handoff that did not happen. Numbered lists are the fallback where no prompt mechanism exists. -- **References are clickable where they are read**: a pull request, issue, or commit on a Markdown surface is a Markdown link, and on a surface that renders neither, a bare `#123` with the link in the message before the prompt. -- **Capability is not permission.** A token's reach, a tool that happens to work, or a similar grant in a past session authorizes nothing, and the irreversible step (merge, publish, release, delete) stays the maintainer's. - -## When a Failure Surfaces a Lesson - -- **Durable knowledge lands in the committed docs, not in agent memory**, as part of the change that surfaced it, per `GOVERNANCE.md` "Durable Knowledge and Self-Improvement". Memory does not survive a new session or machine, so it holds only environment nuance and in-flight state. -- **Where the governing doc is carried from the hub, propose the fix upstream** rather than only patching it locally, since a local fix leaves every sibling repo with the same trap. -- **A review flags an instance, so fix the class**: sweep for the siblings before replying, because reviewers sample rather than enumerate. -- **A rule that keeps needing restating** is usually a stale or missing skills install, so run `python3 scripts/skills_install.py --report` from a hub checkout (the `fleet-conformance-check` skill) before concluding the rule does not exist. - -## Delegation, in One Paragraph - -The always-on rules live in `AGENTS.md` "Context and Delegation Discipline" and are not restated here. The two that intersect conduct: brief a subagent so it never needs a governance file, since anything it must honor has to be in its prompt, and never tier down the seat holding the judgment, because governance wording and the decision to decline a review finding are fleet-wide and durable when wrong. +--- +name: agent-conduct +description: >- + Surfaces the ptr727/ProjectTemplate fleet's conduct rules at the three decision moments they are violated: about to claim work is done, verified, green, or fixed, about to proceed on an assumption the user could cheaply confirm, and a failure or review finding just surfaced a durable lesson. Use this whenever about to report success or completion of any task, whenever about to pick a default, guess an intent, or resolve an ambiguity without asking, whenever work is blocked on a decision or authorization only the user can give, and whenever an incident, a wrong answer, or a repeated correction just taught something a future session must honor. Deliberately narrow: the carried AGENTS.md sections are the always-on layer, and this skill fires at the moments rather than duplicating them, so do not load it as general background. Where a sibling skill owns the moment, it wins: git-commit-conventions for committing, pr-review-conduct for review and merge claims, comment-and-doc-style for prose. The GOVERNANCE.md sections this skill summarizes keep the full rules. +--- + +# Agent Conduct + +## Why This Exists + +The fleet's conduct rules (verification before claiming done, asking instead of assuming, recording lessons) lived only in doc sections nothing surfaced at the moment of violation, so they were honored by whoever happened to have read them recently. This skill is the decision-moment surface. The full rules stay in `GOVERNANCE.md` ("Verification Discipline", "Communicating with the User", "Durable Knowledge and Self-Improvement"), which keeps authority, and in the carried `AGENTS.md` "Context and Delegation Discipline" section, which is the always-on layer. + +## Before Claiming Done + +Read `GOVERNANCE.md` "Verification Discipline" before reporting success on anything non-trivial. Its unifying property: every failure it lists is green. The checks that bind here: + +- **A green check is not evidence the work happened.** A skipped job and a passing job are indistinguishable in an aggregated required check, so confirm from the log that the job ran and produced what it promises. +- **Locate every check the change owes before running any**, from what the repository declares (`OPERATIONS.md` "Local Verification" beside the workflows), not from what the pipeline happens to run, since part of a contract is routinely unreachable from a runner and green is then the precise signal it was skipped. +- **Run the repo's whole lint gate before every push**, not the parts that look relevant, because the tool most likely to catch a change is often the one it seems least about. +- **A launched process is not a result.** Report the output the wait produced, and where it produced none, that absence is the report. Never name an external cause the record does not carry. +- **A local clone is not the branch it names.** Fetch immediately before reading, or read the live ref, and name the ref and commit in any finding a local read produced. +- **A "does not exist" claim names the branch it was checked against.** A worktree's default branch is not necessarily the one the content lives on: in-flight content on a `release`-model repo lands on `develop` before `main`, per `GOVERNANCE.md` "Branching Model," so check that branch before reporting anything absent repo-wide. +- **A test asserts the mechanism it names, and a gate has to be watched failing.** A case that passes for an incidental reason is worse than no case, because it is later cited as evidence. +- **Platform-specific code is verified only on the platform it runs on.** Reasoning about PowerShell, macOS, or WSL-specific behavior from a different host is not verification, however closely it matches an already-tested equivalent elsewhere. State an untested structural match as exactly that, never in the words used for a tested fact, and when no agent in the loop has access to the target platform, say so and defer or ship it labeled unverified. + +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`. + +## Before Assuming + +- **Ask when the user can cheaply confirm.** An assumption that saves one question and is wrong costs the rework plus the trust, so a genuine ambiguity in intent, scope, or authorization is raised, not resolved by picking the likelier reading. Rules that already answer the question (the committed instruction set) are not ambiguity, so read them first rather than asking what they state. +- **Raise blocked work as a direct interactive prompt** at the point the work stops, per `GOVERNANCE.md` "Communicating with the User": the blocked item is the message, the options offered are the actions themselves, and a handoff buried in a summary paragraph is a handoff that did not happen. Numbered lists are the fallback where no prompt mechanism exists. +- **References are clickable where they are read**: a pull request, issue, or commit on a Markdown surface is a Markdown link, and on a surface that renders neither, a bare `#123` with the link in the message before the prompt. +- **Capability is not permission.** A token's reach, a tool that happens to work, or a similar grant in a past session authorizes nothing, and the irreversible step (merge, publish, release, delete) stays the maintainer's. + +## When a Failure Surfaces a Lesson + +- **Durable knowledge lands in the committed docs, not in agent memory**, as part of the change that surfaced it, per `GOVERNANCE.md` "Durable Knowledge and Self-Improvement". Memory does not survive a new session or machine, so it holds only environment nuance and in-flight state. +- **Where the governing doc is carried from the hub, propose the fix upstream** rather than only patching it locally, since a local fix leaves every sibling repo with the same trap. +- **A review flags an instance, so fix the class**: sweep for the siblings before replying, because reviewers sample rather than enumerate. +- **A rule that keeps needing restating** is usually a stale or missing skills install, so run `python3 scripts/skills_install.py --report` from a hub checkout (the `fleet-conformance-check` skill) before concluding the rule does not exist. + +## Delegation, in One Paragraph + +The always-on rules live in `AGENTS.md` "Context and Delegation Discipline" and are not restated here. The two that intersect conduct: brief a subagent so it never needs a governance file, since anything it must honor has to be in its prompt, and never tier down the seat holding the judgment, because governance wording and the decision to decline a review finding are fleet-wide and durable when wrong. diff --git a/.agents/skills/audit-a-repo/SKILL.md b/.agents/skills/audit-a-repo/SKILL.md index 10609633..01ee0b2b 100644 --- a/.agents/skills/audit-a-repo/SKILL.md +++ b/.agents/skills/audit-a-repo/SKILL.md @@ -1,37 +1,37 @@ ---- -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. ---- - -# Audit a Repo - -## Why This Exists - -The audit is the fleet's measurement procedure, and the two failure shapes it guards against are both silent: a repo judged conformant with no committed evidence, and an audit that quietly edits what it was supposed to measure. `AUDIT.md` in the hub is the procedure and keeps authority. This skill carries the rules that get skipped in practice and says which section owns each step. - -## Before Measuring Anything - -- **Route first.** A repo with no carried instruction set, or a partial one, has a baseline that never arrived rather than drift to report, so it goes to `STANDUP.md` sections 1A and 2 first (`AUDIT.md` section 0). Auditing it anyway produces a report that is all absences and reads as catastrophe. -- **Verify the host.** Run `python3 scripts/host_gate.py --repo ` from the hub checkout before any hub tool, and pass `--repo`, since a bare run skips the target's own `host-tools.json` overlay. A stale tool answers `--version`, looks healthy, and produces a wrong answer. -- **Read `main` as ground truth**, for both workflow models, and read `develop` only to detect divergence (`AUDIT.md` section 1). An `operational` repo's `develop` is mid-flight by design, so conformance work sitting there is un-promoted work, not a defect, and it counts when it reaches `main`. Use `spec/audit.py --branch ` to preview in-flight work, which stamps the override so the finding cannot be mistaken for one against ground truth. - -## Measuring - -- **Resolve the repo's types from `registry/repos.json`** and classify a `classificationPending` entry from the tree (`AUDIT.md` section 2). The applicability gate is `WORKFLOW.md` section 1: a check governing an absent construct is N/A, excluded from the verdict, and never a defect (`AUDIT.md` section 3). -- **Know what the runner does and does not prove.** `spec/audit.py` mechanizes the deterministic subset only: settings, rulesets, secret names, file and section presence, verbatim hashing, interface wiring, Dependabot coverage, branch facts. It evaluates no check under a type in `spec/project-types.json`, so every per-type check is judged by hand, and a clean run is no evidence for them (`AUDIT.md` section 4). Silence from a tool that was never looking reads exactly like a pass. -- **Judge letter and intent per check** and keep the vocabulary: letter miss with intent satisfied is a drift finding, both missing is a defect, and operational is binary over the applicable set (`AUDIT.md` sections 4 and 7). Do not invent a parallel scheme. -- **Assert the Actions implement `WORKFLOW.md`** by outcome, not by matching catalog snippets byte for byte: the 5A static audit with a `file:line` citation per applicable guarantee, then the 5B trace scenarios (`AUDIT.md` section 5). The `workflow-ci-contract` skill summarizes that contract. -- **Diff live settings, rulesets, and secrets** against `repo-config/` with the normalized comparisons `AUDIT.md` section 6 gives, using its commands rather than hand-rolled ones, since the normalization details (rule sorting, `bypass_actors` deliberately excluded, the operational develop payload) are where hand-rolled diffs go wrong. - -## Reporting - -- **Write `reports//audit.md` from `reports/_template.md`**, findings ranked most severe first, each with the `file:line` it was judged against, and quote the run stamp, since findings are a point-in-time snapshot (`AUDIT.md` section 8). -- **The hub authors the report.** A downstream repo never opens a hub pull request to write its own, which would be self-certification. Downstream context goes into issues filed against the hub instead. -- **Generate a convergence issue, never compose one**: `spec/audit.py --issue ` emits it from live findings. An agent picking such an issue up re-runs the audit first and acts on the live result, not the pasted findings. -- **Reconcile registry `driftNotes` in the same pass**: a resolved deviation's note is deleted, not left describing finished work, and a note naming a check id is retired by a person, not by a run (`AUDIT.md` section 8). -- **Stale-versus-modified classification needs a full hub clone with git history.** Without one, compare against the current hub canonical on `main`, which decides current-match only. - -## After the Report - -Measuring and fixing are separate phases. Converging is `AUDIT.md` section 10: fixes ship as pull requests on the target repo, one focused pull request per drift class, the Copilot loop driven to green per the `pr-review-conduct` skill, and the maintainer merges. For a repo already stood up, `RESYNC.md` sequences the findings, since order matters (a deletion lands before the re-vendor that would refresh it). Systemic drift shared by many repos is fixed in the hub spec, not hand-patched per repo, and spec questions are escalated rather than resolved silently (`AUDIT.md` section 9). +--- +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. +--- + +# Audit a Repo + +## Why This Exists + +The audit is the fleet's measurement procedure, and the two failure shapes it guards against are both silent: a repo judged conformant with no committed evidence, and an audit that quietly edits what it was supposed to measure. `AUDIT.md` in the hub is the procedure and keeps authority. This skill carries the rules that get skipped in practice and says which section owns each step. + +## Before Measuring Anything + +- **Route first.** A repo with no carried instruction set, or a partial one, has a baseline that never arrived rather than drift to report, so it goes to `STANDUP.md` sections 1A and 2 first (`AUDIT.md` section 0). Auditing it anyway produces a report that is all absences and reads as catastrophe. +- **Verify the host.** Run `python3 scripts/host_gate.py --repo ` from the hub checkout before any hub tool, and pass `--repo`, since a bare run skips the target's own `host-tools.json` overlay. A stale tool answers `--version`, looks healthy, and produces a wrong answer. +- **Read `main` as ground truth**, for both workflow models, and read `develop` only to detect divergence (`AUDIT.md` section 1). An `operational` repo's `develop` is mid-flight by design, so conformance work sitting there is un-promoted work, not a defect, and it counts when it reaches `main`. Use `spec/audit.py --branch ` to preview in-flight work, which stamps the override so the finding cannot be mistaken for one against ground truth. + +## Measuring + +- **Resolve the repo's types from `registry/repos.json`** and classify a `classificationPending` entry from the tree (`AUDIT.md` section 2). The applicability gate is `WORKFLOW.md` section 1: a check governing an absent construct is N/A, excluded from the verdict, and never a defect (`AUDIT.md` section 3). +- **Know what the runner does and does not prove.** `spec/audit.py` mechanizes the deterministic subset only: settings, rulesets, secret names, file and section presence, verbatim hashing, interface wiring, Dependabot coverage, branch facts. It evaluates no check under a type in `spec/project-types.json`, so every per-type check is judged by hand, and a clean run is no evidence for them (`AUDIT.md` section 4). Silence from a tool that was never looking reads exactly like a pass. +- **Judge letter and intent per check** and keep the vocabulary: letter miss with intent satisfied is a drift finding, both missing is a defect, and operational is binary over the applicable set (`AUDIT.md` sections 4 and 7). Do not invent a parallel scheme. +- **Assert the Actions implement `WORKFLOW.md`** by outcome, not by matching catalog snippets byte for byte: the 5A static audit with a `file:line` citation per applicable guarantee, then the 5B trace scenarios (`AUDIT.md` section 5). The `workflow-ci-contract` skill summarizes that contract. +- **Diff live settings, rulesets, and secrets** against `repo-config/` with the normalized comparisons `AUDIT.md` section 6 gives, using its commands rather than hand-rolled ones, since the normalization details (rule sorting, `bypass_actors` deliberately excluded, the operational develop payload) are where hand-rolled diffs go wrong. + +## Reporting + +- **Write `reports//audit.md` from `reports/_template.md`**, findings ranked most severe first, each with the `file:line` it was judged against, and quote the run stamp, since findings are a point-in-time snapshot (`AUDIT.md` section 8). +- **The hub authors the report.** A downstream repo never opens a hub pull request to write its own, which would be self-certification. Downstream context goes into issues filed against the hub instead. +- **Generate a convergence issue, never compose one**: `spec/audit.py --issue ` emits it from live findings. An agent picking such an issue up re-runs the audit first and acts on the live result, not the pasted findings. +- **Reconcile registry `driftNotes` in the same pass**: a resolved deviation's note is deleted, not left describing finished work, and a note naming a check id is retired by a person, not by a run (`AUDIT.md` section 8). +- **Stale-versus-modified classification needs a full hub clone with git history.** Without one, compare against the current hub canonical on `main`, which decides current-match only. + +## After the Report + +Measuring and fixing are separate phases. Converging is `AUDIT.md` section 10: fixes ship as pull requests on the target repo, one focused pull request per drift class, the Copilot loop driven to green per the `pr-review-conduct` skill, and the maintainer merges. For a repo already stood up, `RESYNC.md` sequences the findings, since order matters (a deletion lands before the re-vendor that would refresh it). Systemic drift shared by many repos is fixed in the hub spec, not hand-patched per repo, and spec questions are escalated rather than resolved silently (`AUDIT.md` section 9). diff --git a/.agents/skills/carried-instruction-file-guard/SKILL.md b/.agents/skills/carried-instruction-file-guard/SKILL.md index 012fc4e8..47f88033 100644 --- a/.agents/skills/carried-instruction-file-guard/SKILL.md +++ b/.agents/skills/carried-instruction-file-guard/SKILL.md @@ -1,31 +1,31 @@ ---- -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. ---- - -# Carried Instruction File Guard - -## Why this exists - -A downstream repo's `AGENTS.md`/`GOVERNANCE.md`/`CODESTYLE.md`/`WORKFLOW.md` can hold two different kinds of content mixed in one file: sections that are stale copies of the hub's fleet-wide rules, and local rules the repo wrote for a fault the fleet has never seen elsewhere. Re-vendoring the hub's canonical version over the whole file deletes the second kind silently, because nothing about the diff looks wrong. This has actually happened: a resync replaced a repo's `AGENTS.md` wholesale with the hub's, and the repo's own local additions were gone with no error, no warning, and no review comment calling it out. - -The fix is not "be careful." Being careful is what failed the first time. The fix is a mechanical check you run before any overwrite touches one of these four files, every time, regardless of how routine the request sounds. - -## Before you touch any of these four files - -1. **Check whether the file's content is declared `verbatim` or `intent`.** The hub's `spec/section-model.md` (fetch it from a hub checkout, `github.com/ptr727/ProjectTemplate`, if you don't have one) names, section by section, which parts of `AGENTS.md` and `GOVERNANCE.md` are universal fleet law (safe to byte-match against the hub) and which describe the repo itself (never safe to overwrite from another repo). `CODESTYLE.md` and `WORKFLOW.md` are carried whole at `intent` fidelity, judged by meaning, not hashed. -2. **If any part of the file is `intent`, or if the file predates a clean split into hub-governed sections, do not diff-and-replace. Probe instead.** For each rule or paragraph in the current file that is not obviously boilerplate: - - Pick the phrase in it that is most peculiar to this repo, not generic governance vocabulary. A rule about "always sign commits" is generic. A rule about "this repo's Docker image pins Alpine 3.19 because 3.20 broke the s6 supervisor" is peculiar. - - Grep the hub's canonical copy of the same file for that peculiar phrase. - - **Absent from the hub canonical means it is a local addition.** It is never dropped because it looks similar to something else, and never dropped because a merge or overwrite would be simpler without it. -3. **A local addition found by the probe gets a destination, not a deletion.** Either it names a rule that should apply fleet-wide (flag it for the maintainer to promote into the hub), or it is genuinely specific to this repo and moves to the repo's own topical doc before the carried file is touched: `CODESTYLE.md` for a language/formatting convention, `ARCHITECTURE.md` for a design decision, `OPERATIONS.md` for a runbook or operational note, `TODO.md` for backlog. Move it, confirm it is not lost, and only then proceed with the carry. -4. **Do not trust a similarity or word-overlap check for step 2.** A repo-specific rule written in ordinary governance language reads as a reworded duplicate of an unrelated hub rule to that kind of check, and it will confidently tell you the local content is redundant when it is not. Exact phrase presence or absence is the only check that has held up. - -## What is actually safe to overwrite without this procedure - -A section `spec/section-model.md` names as `verbatim`, in a file that is already cleanly split (the file carries only that declared section, nothing else mixed in), can be re-vendored directly: byte-matching it against the hub canonical is the point of `verbatim` fidelity, and the audit already checks it that way. The guard above is for everything else: `intent`-fidelity content, a file that has not been split yet, or any file you are not certain is clean. - -## If you are not sure which case you are in - -Stop and say so, rather than guessing. Naming the uncertainty costs one sentence. Silently overwriting the wrong thing costs someone's local rules with no way to notice until much later. +--- +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. +--- + +# Carried Instruction File Guard + +## Why this exists + +A downstream repo's `AGENTS.md`/`GOVERNANCE.md`/`CODESTYLE.md`/`WORKFLOW.md` can hold two different kinds of content mixed in one file: sections that are stale copies of the hub's fleet-wide rules, and local rules the repo wrote for a fault the fleet has never seen elsewhere. Re-vendoring the hub's canonical version over the whole file deletes the second kind silently, because nothing about the diff looks wrong. This has actually happened: a resync replaced a repo's `AGENTS.md` wholesale with the hub's, and the repo's own local additions were gone with no error, no warning, and no review comment calling it out. + +The fix is not "be careful." Being careful is what failed the first time. The fix is a mechanical check you run before any overwrite touches one of these four files, every time, regardless of how routine the request sounds. + +## Before you touch any of these four files + +1. **Check whether the file's content is declared `verbatim` or `intent`.** The hub's `spec/section-model.md` (fetch it from a hub checkout, `github.com/ptr727/ProjectTemplate`, if you don't have one) names, section by section, which parts of `AGENTS.md` and `GOVERNANCE.md` are universal fleet law (safe to byte-match against the hub) and which describe the repo itself (never safe to overwrite from another repo). `CODESTYLE.md` and `WORKFLOW.md` are carried whole at `intent` fidelity, judged by meaning, not hashed. +2. **If any part of the file is `intent`, or if the file predates a clean split into hub-governed sections, do not diff-and-replace. Probe instead.** For each rule or paragraph in the current file that is not obviously boilerplate: + - Pick the phrase in it that is most peculiar to this repo, not generic governance vocabulary. A rule about "always sign commits" is generic. A rule about "this repo's Docker image pins Alpine 3.19 because 3.20 broke the s6 supervisor" is peculiar. + - Grep the hub's canonical copy of the same file for that peculiar phrase. + - **Absent from the hub canonical means it is a local addition.** It is never dropped because it looks similar to something else, and never dropped because a merge or overwrite would be simpler without it. +3. **A local addition found by the probe gets a destination, not a deletion.** Either it names a rule that should apply fleet-wide (flag it for the maintainer to promote into the hub), or it is genuinely specific to this repo and moves to the repo's own topical doc before the carried file is touched: `CODESTYLE.md` for a language/formatting convention, `ARCHITECTURE.md` for a design decision, `OPERATIONS.md` for a runbook or operational note, `TODO.md` for backlog. Move it, confirm it is not lost, and only then proceed with the carry. +4. **Do not trust a similarity or word-overlap check for step 2.** A repo-specific rule written in ordinary governance language reads as a reworded duplicate of an unrelated hub rule to that kind of check, and it will confidently tell you the local content is redundant when it is not. Exact phrase presence or absence is the only check that has held up. + +## What is actually safe to overwrite without this procedure + +A section `spec/section-model.md` names as `verbatim`, in a file that is already cleanly split (the file carries only that declared section, nothing else mixed in), can be re-vendored directly: byte-matching it against the hub canonical is the point of `verbatim` fidelity, and the audit already checks it that way. The guard above is for everything else: `intent`-fidelity content, a file that has not been split yet, or any file you are not certain is clean. + +## If you are not sure which case you are in + +Stop and say so, rather than guessing. Naming the uncertainty costs one sentence. Silently overwriting the wrong thing costs someone's local rules with no way to notice until much later. diff --git a/.agents/skills/comment-and-doc-style/SKILL.md b/.agents/skills/comment-and-doc-style/SKILL.md index e383d705..dc834f25 100644 --- a/.agents/skills/comment-and-doc-style/SKILL.md +++ b/.agents/skills/comment-and-doc-style/SKILL.md @@ -1,231 +1,231 @@ ---- -name: comment-and-doc-style -description: >- - Governs prose, comment, Markdown, character-set, line-ending, and PR-title/commit-message - conventions for every ptr727/ProjectTemplate fleet repo. Use this whenever writing or editing a - code comment, workflow comment, Markdown doc, commit message, or PR title, whenever choosing - which characters to type in agent-authored text, whenever the file being edited is CRLF, and - whenever naming a tool in prose or docs. Triggers even when the task looks purely mechanical, - such as "just fix a typo" or "add a one-line comment", because the fleet's ASCII character-set - tiers, no-semicolon rule, comment-growth discipline, and CRLF-preservation rule are each easy to - violate without noticing: an em dash slipped into a sentence, a comment that grew by one more - clause, or a text-mode edit that silently flattens a CRLF file to LF. Also triggers when - authoring a new Markdown file (reference-style links, Table of Contents, present tense), when a - carried instruction file (AGENTS.md, GOVERNANCE.md, CODESTYLE.md, WORKFLOW.md, - .github/copilot-instructions.md) is being edited (no coordination references to the template or - a sibling repo), and when writing a PR title or commit message (imperative subject, no vague - titles, no unsolicited Co-Authored-By, no release-bump magnitude). ---- - -# Comment and Doc Style - -## Why this exists - -These are the fleet's mechanical prose rules, kept in one place instead of re-derived per repo or -per session: how to write a comment, which characters an agent may type, how a Markdown file is -structured, how a carried instruction file may reference the hub, and how a PR title or commit -message reads. None of these are matters of taste. Each is checked, by `prose_lint.py`, -`editorconfig-checker`, `markdownlint`, `cspell`, or a human reviewer, and each has been the exact -subject of a real review finding. - -## Naming tools in prose - -Use each tool's official casing in task labels, docs, and prose: `.NET` (not `.Net`), -`CSharpier`, `ruff`, `pyright`, `uv`. Do not invent personal variants. - -## Markdown files: linting and spelling - -- **Markdown lints clean, repo-wide.** Every `.md` file is error and warning free via - `markdownlint-cli2` against the shared `.markdownlint-cli2.jsonc`. A rule it deliberately - 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. -- **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. -- **CI's spelling gate covers `README.md` and `HISTORY.md` only**, deliberately not every `.md` - file, so a new topical doc is not spell-gated in CI (the editor extension still flags it live). - A repo may widen its own CI list, README plus HISTORY is the default. A repo shipping no - `HISTORY.md` drops it from the CI workflow, the `Lint: Spelling` task, and the GOVERNANCE.md - cspell line together, all three or none. -- **`HISTORY.md` mirrors the README's opening**: the same `# `, the same tagline verbatim - (the first line after the README's H1), then its own `## Release History`. It never repeats a - paragraph below the README's tagline. -- **"Markdown" is a proper noun in prose** (a Markdown file, a Markdown-only repo), lowercase only - for what a machine reads: a tool or package name (`markdownlint`), a settings key, a heading - anchor, a file extension. - -## Markdown formatting - -- **Reference-style links everywhere**, except the four files read one section at a time rather - than end to end: `AGENTS.md`, `GOVERNANCE.md`, `OPERATIONS.md`, `.github/copilot-instructions.md`. - Those keep inline links so a target resolves where it is read. Every other Markdown file defines - every URI at the bottom, grouped by type under an HTML-comment header, each group alphabetized - by reference name rather than by the full definition line (a name that is a prefix of another - sorts first, `[governance]` above `[governance-branching-model]`). A URL inside a fenced code - block stays inline. See `references/markdown-links.md` for the full grouping and naming - convention. -- **Table of Contents**: generated by the Markdown All in One extension on save, never - hand-authored or hand-edited. Exclude a heading with an inline `<!-- omit from toc -->` marker. -- **One logical paragraph per line**, no hard-wrap line-length limit. For an intentional line - break within a block (stacked badges, status lines), end the line with a trailing backslash - rather than trailing whitespace. -- **Headings use the PR-title casing rule** below. -- **Write in the present tense.** State what *is*, never a change from a prior state ("X does Y", - not "X now does Y" or "X no longer does Z"). This applies to docs and code/workflow comments - alike. Before/after framing belongs in changelogs, commit messages, and PR descriptions, where - the prior state is the point. -- **When a behavior changes, grep for prose asserting the old one.** Comments, diagram labels, - workflow-input descriptions, and audit statements elsewhere may still describe the prior - behavior, and each was accurate when written. No linter catches a claim that is merely untrue, - so this sweep is the only mechanism that will. - -## Sentence structure - -The structural half of ASD-STE100 is the adopted house style for agent-authored prose, and the -controlled dictionary is deliberately not adopted: vocabulary stays unrestricted, structure is -restricted. Each structural rule a pattern can reach lands as a `prose_lint.py` check -incrementally, and this section names each check as it ships. - -- **Short sentences: at most 25 words in one sentence**, ASD-STE100's descriptive cap, checked by - the `sentence-length` rule in `prose_lint.py`. The check is opt-in like `sentence-split`, - because the existing corpus predates the cap and a default gate would fail whole files nobody - is editing. Write new prose under the cap, and scope a run to a change with - `--check sentence-length --diff <base>`. -- **One instruction per sentence.** A procedure step states one action, and a second action is a - second step. No pattern reaches this, so it is authoring discipline with no check. -- **Active voice, imperative mood for procedure steps.** Write "run the gate", never "the gate - should be run". Also authoring discipline, since a reliable passive-voice pattern does not - exist. - -## Comments - -Applies to code and workflow (`#`) comments alike. - -- Comment only when the code does not explain itself, or the logic is genuinely complex. - Self-evident code needs no comment. -- State only the non-obvious *why*, for the human reading *this* project's code now. No - cross-project references, no historic or design narrative, no rule citations. Governance lives - in the fleet's own instruction set, not echoed inline. -- **Keep it short**: one line is the default. A second line is earned only by a constraint the - code cannot otherwise carry. -- **Structured, not prose**: one sentence per line, never wrapped across lines, never a - multi-sentence run-on. A comment that genuinely needs several sentences is several lines, each - one sentence. -- A comment line opening prose starts with a capital. A trailing label, or the version pin an - action-pinning rule requires, does not. -- Mark a sub-topic with `-` after the comment marker (`# -`), only for genuine parallel sub-items - hanging off a lead line, never a continuation of one thought. -- **No file, class, or type header summary blocks.** A type or file gets a comment only for a - specific non-obvious point, never a block restating what it contains (a license or provenance - header a tool or policy requires is not a summary and is unaffected). -- **Never let a comment grow across edits.** Touching code near an existing comment means the - comment comes out the same length or shorter, never one more clause of rationale appended. - -A continuation stays unindented, one sentence per line: - -```text -# Change gate for the compile tests. -# An esp-idf build costs minutes, so gate on what each test covers. -# A diff that cannot be computed runs everything. -``` - -Sub-topics take a `-` after the comment marker, each elaborating a distinct item named in the lead: - -```text -# Source lint plus change-gated compile tests. -# - compile-test builds the external component. -# - template-compile-test builds one example device per template. -``` - -## Character set - -Agent-authored text is ASCII by default: documentation, code, comments, commit messages, and PR -descriptions. A non-ASCII character is read against three tiers, because whether one is -typography or meaning depends on where it sits. A character in no tier is a finding rather than a -silent pass. - -- **Tier 1, never legitimate.** Typography carrying no meaning its ASCII form loses. Remove on - sight: - - em dash (U+2014) and en dash (U+2013) to a restructured sentence, two sentences or a comma, - never a spaced hyphen - - right arrow (U+2192) to `->`, double arrow (U+21D2) to `=>` - - curly quotes (U+2018/U+2019/U+201C/U+201D) to straight `'` and `"` - - ellipsis (U+2026) to `...`, bullet (U+2022) to `-` - - no-break space (U+00A0) to a space, non-breaking hyphen (U+2011) to `-` -- **Tier 2, legitimate only next to a number.** Relational and arithmetic operators: U+2264, - U+2265, U+2260, U+00B1, U+2212, U+00D7, U+00F7, U+00B7. Keep one when an adjacent non-space token - is a number, a tier-3 symbol, or another tier-2 operator, so a threshold table or a measured - range reads as the range it is. In flowing prose write the ASCII form: `<=`, `>=`, `!=`, `+/-`, - `-`, `x`, `/`. A tier-2 operator directly before a number in a table of thresholds is the range - it describes and stays, the same character between two words in a sentence is prose and takes - the ASCII form. -- **Tier 3, always legitimate.** Scientific and unit symbols whose ASCII form would be a lie: - micro (U+00B5), degree (U+00B0), ohm (U+2126), pi (U+03C0), superscript two and three (U+00B2, - U+00B3), section (U+00A7). Keep the symbol, never approximate it away or spell it out. -- **Unicode a developer deliberately typed** stays regardless of tier, such as emoji used for - emphasis or as callout markers. Never strip a developer's own characters, this is developer - authored text and not a license for the agent to add its own. -- **An unrecognized non-ASCII character is reported, not allowed.** Classify it into a tier above - before using it. -- **No semicolon in agent-authored prose.** Recast a mid-sentence semicolon as a comma or as two - sentences. A semicolon separating items in a list that already contains commas, or a statement - terminator in code, is unaffected. -- **No spaced hyphen joining or interrupting a sentence** (` - `, or the paired aside ` - x - `). - Recast as a comma, two sentences, or parentheses. A hyphen inside a compound word, a leading - list marker, a range, and the `- **Label** - explanation` bullet separator are unaffected. -- **In carried verbatim content, fix the whole class at the hub**, not one instance, since a - downstream repo cannot edit a section byte-matched against the hub. Everywhere else, correct as - each file is next edited, not swept. - -## Line endings - -This repo's default is CRLF (`[*] end_of_line = crlf` in `.editorconfig`), with LF pinned for -execution-sensitive files (`*.sh`, Dockerfiles, a shebang-executed `.py` pinned by path). -**Preserve a file's existing line ending when editing it, never reflow as a side effect of a -content change.** A text-mode tool, including a naive programmatic write, can silently flip CRLF -to LF and turn a one-line change into a whole-file diff. After any programmatic edit, verify with -`git diff --stat` (it should touch only the lines you changed) and a byte scan, `file` and a naive -`git ls-files --eol` are both unreliable here. Idempotent normalize: -`b.replace(b"\r\n", b"\n").replace(b"\n", b"\r\n")`. The full policy, choosing an ending for a new -file type, operational-repo overrides, extensionless-script pins, and auditing, is in -`references/line-endings.md`. - -## Carried files reference no coordination machinery - -`AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, `WORKFLOW.md`, `.github/copilot-instructions.md`, -the `repo-config/` and `spec/` files, and the carried `AUDIT.md` never reference the template repo -(in prose or a link), and never name a sibling fleet repo as an illustrative example. State the -behavior a carried rule needs, not the coordination flow that produced it, the maintainer supplies -the destination out of band. A contextually relevant link to a related project (the image this -config feeds, a library this depends on) is not a coordination reference and is expected. The full -exceptions, a verbatim section that must name the hub to do its job, and a pointer to a -hub-hosted tool the reader runs, are in `references/carried-doc-references.md`. - -## PR titles and commit messages - -- **Format**: an imperative subject, 72 characters or fewer, no trailing period ("Add 24-hour - PM2.5 average sensor", not "Added X" or "Adds X"). An optional body, blank-line separated, - explains *why* the change is being made when that is non-obvious, the diff already shows *what*. -- **Rules**: no vague titles (`update stuff`, `wip`). Dependabot's default `Bump X from Y to Z` - titles are fine as-is. No `Co-Authored-By:` lines unless the developer explicitly asks. No - release-bump magnitude in the title ("minor", "patch", "release v0.2.0"), Nerdbank.GitVersioning - computes the next version from `version.json` and git history, a dependency version in a - dependency-bump title is fine and expected. US English spelling, and title case with lowercase - short bind words (a, an, the, and, but, or, of, in, on, at, to, by, for, from), a hyphenated - compound capitalizes both parts unless the second is a short preposition (*Built-in*, - *EPA-Corrected*, *24-Hour*). - -```text -Add structured logging extensions to library -Pin softprops/action-gh-release to commit SHA -Drop net8.0 multi-targeting from console project -Bump xunit.v3 from 3.2.2 to 3.3.0 -Clarify devcontainer setup steps in README -``` - -## Quantitative claims - -A quantitative claim in `README.md` (a count, a size, a version floor, a supported-platform list) -is verified against current code before it is written. When a doc number is derived from a code -constant, mark the dependency in a source-code comment so the next editor knows to update both. +--- +name: comment-and-doc-style +description: >- + Governs prose, comment, Markdown, character-set, line-ending, and PR-title/commit-message + conventions for every ptr727/ProjectTemplate fleet repo. Use this whenever writing or editing a + code comment, workflow comment, Markdown doc, commit message, or PR title, whenever choosing + which characters to type in agent-authored text, whenever the file being edited is CRLF, and + whenever naming a tool in prose or docs. Triggers even when the task looks purely mechanical, + such as "just fix a typo" or "add a one-line comment", because the fleet's ASCII character-set + tiers, no-semicolon rule, comment-growth discipline, and CRLF-preservation rule are each easy to + violate without noticing: an em dash slipped into a sentence, a comment that grew by one more + clause, or a text-mode edit that silently flattens a CRLF file to LF. Also triggers when + authoring a new Markdown file (reference-style links, Table of Contents, present tense), when a + carried instruction file (AGENTS.md, GOVERNANCE.md, CODESTYLE.md, WORKFLOW.md, + .github/copilot-instructions.md) is being edited (no coordination references to the template or + a sibling repo), and when writing a PR title or commit message (imperative subject, no vague + titles, no unsolicited Co-Authored-By, no release-bump magnitude). +--- + +# Comment and Doc Style + +## Why this exists + +These are the fleet's mechanical prose rules, kept in one place instead of re-derived per repo or +per session: how to write a comment, which characters an agent may type, how a Markdown file is +structured, how a carried instruction file may reference the hub, and how a PR title or commit +message reads. None of these are matters of taste. Each is checked, by `prose_lint.py`, +`editorconfig-checker`, `markdownlint`, `cspell`, or a human reviewer, and each has been the exact +subject of a real review finding. + +## Naming tools in prose + +Use each tool's official casing in task labels, docs, and prose: `.NET` (not `.Net`), +`CSharpier`, `ruff`, `pyright`, `uv`. Do not invent personal variants. + +## Markdown files: linting and spelling + +- **Markdown lints clean, repo-wide.** Every `.md` file is error and warning free via + `markdownlint-cli2` against the shared `.markdownlint-cli2.jsonc`. A rule it deliberately + 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. +- **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. +- **CI's spelling gate covers `README.md` and `HISTORY.md` only**, deliberately not every `.md` + file, so a new topical doc is not spell-gated in CI (the editor extension still flags it live). + A repo may widen its own CI list, README plus HISTORY is the default. A repo shipping no + `HISTORY.md` drops it from the CI workflow, the `Lint: Spelling` task, and the GOVERNANCE.md + cspell line together, all three or none. +- **`HISTORY.md` mirrors the README's opening**: the same `# <Title>`, the same tagline verbatim + (the first line after the README's H1), then its own `## Release History`. It never repeats a + paragraph below the README's tagline. +- **"Markdown" is a proper noun in prose** (a Markdown file, a Markdown-only repo), lowercase only + for what a machine reads: a tool or package name (`markdownlint`), a settings key, a heading + anchor, a file extension. + +## Markdown formatting + +- **Reference-style links everywhere**, except the four files read one section at a time rather + than end to end: `AGENTS.md`, `GOVERNANCE.md`, `OPERATIONS.md`, `.github/copilot-instructions.md`. + Those keep inline links so a target resolves where it is read. Every other Markdown file defines + every URI at the bottom, grouped by type under an HTML-comment header, each group alphabetized + by reference name rather than by the full definition line (a name that is a prefix of another + sorts first, `[governance]` above `[governance-branching-model]`). A URL inside a fenced code + block stays inline. See `references/markdown-links.md` for the full grouping and naming + convention. +- **Table of Contents**: generated by the Markdown All in One extension on save, never + hand-authored or hand-edited. Exclude a heading with an inline `<!-- omit from toc -->` marker. +- **One logical paragraph per line**, no hard-wrap line-length limit. For an intentional line + break within a block (stacked badges, status lines), end the line with a trailing backslash + rather than trailing whitespace. +- **Headings use the PR-title casing rule** below. +- **Write in the present tense.** State what *is*, never a change from a prior state ("X does Y", + not "X now does Y" or "X no longer does Z"). This applies to docs and code/workflow comments + alike. Before/after framing belongs in changelogs, commit messages, and PR descriptions, where + the prior state is the point. +- **When a behavior changes, grep for prose asserting the old one.** Comments, diagram labels, + workflow-input descriptions, and audit statements elsewhere may still describe the prior + behavior, and each was accurate when written. No linter catches a claim that is merely untrue, + so this sweep is the only mechanism that will. + +## Sentence structure + +The structural half of ASD-STE100 is the adopted house style for agent-authored prose, and the +controlled dictionary is deliberately not adopted: vocabulary stays unrestricted, structure is +restricted. Each structural rule a pattern can reach lands as a `prose_lint.py` check +incrementally, and this section names each check as it ships. + +- **Short sentences: at most 25 words in one sentence**, ASD-STE100's descriptive cap, checked by + the `sentence-length` rule in `prose_lint.py`. The check is opt-in like `sentence-split`, + because the existing corpus predates the cap and a default gate would fail whole files nobody + is editing. Write new prose under the cap, and scope a run to a change with + `--check sentence-length --diff <base>`. +- **One instruction per sentence.** A procedure step states one action, and a second action is a + second step. No pattern reaches this, so it is authoring discipline with no check. +- **Active voice, imperative mood for procedure steps.** Write "run the gate", never "the gate + should be run". Also authoring discipline, since a reliable passive-voice pattern does not + exist. + +## Comments + +Applies to code and workflow (`#`) comments alike. + +- Comment only when the code does not explain itself, or the logic is genuinely complex. + Self-evident code needs no comment. +- State only the non-obvious *why*, for the human reading *this* project's code now. No + cross-project references, no historic or design narrative, no rule citations. Governance lives + in the fleet's own instruction set, not echoed inline. +- **Keep it short**: one line is the default. A second line is earned only by a constraint the + code cannot otherwise carry. +- **Structured, not prose**: one sentence per line, never wrapped across lines, never a + multi-sentence run-on. A comment that genuinely needs several sentences is several lines, each + one sentence. +- A comment line opening prose starts with a capital. A trailing label, or the version pin an + action-pinning rule requires, does not. +- Mark a sub-topic with `-` after the comment marker (`# -`), only for genuine parallel sub-items + hanging off a lead line, never a continuation of one thought. +- **No file, class, or type header summary blocks.** A type or file gets a comment only for a + specific non-obvious point, never a block restating what it contains (a license or provenance + header a tool or policy requires is not a summary and is unaffected). +- **Never let a comment grow across edits.** Touching code near an existing comment means the + comment comes out the same length or shorter, never one more clause of rationale appended. + +A continuation stays unindented, one sentence per line: + +```text +# Change gate for the compile tests. +# An esp-idf build costs minutes, so gate on what each test covers. +# A diff that cannot be computed runs everything. +``` + +Sub-topics take a `-` after the comment marker, each elaborating a distinct item named in the lead: + +```text +# Source lint plus change-gated compile tests. +# - compile-test builds the external component. +# - template-compile-test builds one example device per template. +``` + +## Character set + +Agent-authored text is ASCII by default: documentation, code, comments, commit messages, and PR +descriptions. A non-ASCII character is read against three tiers, because whether one is +typography or meaning depends on where it sits. A character in no tier is a finding rather than a +silent pass. + +- **Tier 1, never legitimate.** Typography carrying no meaning its ASCII form loses. Remove on + sight: + - em dash (U+2014) and en dash (U+2013) to a restructured sentence, two sentences or a comma, + never a spaced hyphen + - right arrow (U+2192) to `->`, double arrow (U+21D2) to `=>` + - curly quotes (U+2018/U+2019/U+201C/U+201D) to straight `'` and `"` + - ellipsis (U+2026) to `...`, bullet (U+2022) to `-` + - no-break space (U+00A0) to a space, non-breaking hyphen (U+2011) to `-` +- **Tier 2, legitimate only next to a number.** Relational and arithmetic operators: U+2264, + U+2265, U+2260, U+00B1, U+2212, U+00D7, U+00F7, U+00B7. Keep one when an adjacent non-space token + is a number, a tier-3 symbol, or another tier-2 operator, so a threshold table or a measured + range reads as the range it is. In flowing prose write the ASCII form: `<=`, `>=`, `!=`, `+/-`, + `-`, `x`, `/`. A tier-2 operator directly before a number in a table of thresholds is the range + it describes and stays, the same character between two words in a sentence is prose and takes + the ASCII form. +- **Tier 3, always legitimate.** Scientific and unit symbols whose ASCII form would be a lie: + micro (U+00B5), degree (U+00B0), ohm (U+2126), pi (U+03C0), superscript two and three (U+00B2, + U+00B3), section (U+00A7). Keep the symbol, never approximate it away or spell it out. +- **Unicode a developer deliberately typed** stays regardless of tier, such as emoji used for + emphasis or as callout markers. Never strip a developer's own characters, this is developer + authored text and not a license for the agent to add its own. +- **An unrecognized non-ASCII character is reported, not allowed.** Classify it into a tier above + before using it. +- **No semicolon in agent-authored prose.** Recast a mid-sentence semicolon as a comma or as two + sentences. A semicolon separating items in a list that already contains commas, or a statement + terminator in code, is unaffected. +- **No spaced hyphen joining or interrupting a sentence** (` - `, or the paired aside ` - x - `). + Recast as a comma, two sentences, or parentheses. A hyphen inside a compound word, a leading + list marker, a range, and the `- **Label** - explanation` bullet separator are unaffected. +- **In carried verbatim content, fix the whole class at the hub**, not one instance, since a + downstream repo cannot edit a section byte-matched against the hub. Everywhere else, correct as + each file is next edited, not swept. + +## Line endings + +This repo's default is LF (`[*] end_of_line = lf` in `.editorconfig`), with CRLF pinned only for +`*.bat` and `*.cmd`, the one type Windows itself requires it for. +**Preserve a file's existing line ending when editing it, never reflow as a side effect of a +content change.** A text-mode tool, including a naive programmatic write, can silently flip CRLF +to LF and turn a one-line change into a whole-file diff. After any programmatic edit, verify with +`git diff --stat` (it should touch only the lines you changed) and a byte scan, `file` and a naive +`git ls-files --eol` are both unreliable here. Idempotent normalize: +`b.replace(b"\r\n", b"\n").replace(b"\n", b"\r\n")`. The full policy, choosing an ending for a new +file type, operational-repo overrides, extensionless-script pins, and auditing, is in +`references/line-endings.md`. + +## Carried files reference no coordination machinery + +`AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, `WORKFLOW.md`, `.github/copilot-instructions.md`, +the `repo-config/` and `spec/` files, and the carried `AUDIT.md` never reference the template repo +(in prose or a link), and never name a sibling fleet repo as an illustrative example. State the +behavior a carried rule needs, not the coordination flow that produced it, the maintainer supplies +the destination out of band. A contextually relevant link to a related project (the image this +config feeds, a library this depends on) is not a coordination reference and is expected. The full +exceptions, a verbatim section that must name the hub to do its job, and a pointer to a +hub-hosted tool the reader runs, are in `references/carried-doc-references.md`. + +## PR titles and commit messages + +- **Format**: an imperative subject, 72 characters or fewer, no trailing period ("Add 24-hour + PM2.5 average sensor", not "Added X" or "Adds X"). An optional body, blank-line separated, + explains *why* the change is being made when that is non-obvious, the diff already shows *what*. +- **Rules**: no vague titles (`update stuff`, `wip`). Dependabot's default `Bump X from Y to Z` + titles are fine as-is. No `Co-Authored-By:` lines unless the developer explicitly asks. No + release-bump magnitude in the title ("minor", "patch", "release v0.2.0"), Nerdbank.GitVersioning + computes the next version from `version.json` and git history, a dependency version in a + dependency-bump title is fine and expected. US English spelling, and title case with lowercase + short bind words (a, an, the, and, but, or, of, in, on, at, to, by, for, from), a hyphenated + compound capitalizes both parts unless the second is a short preposition (*Built-in*, + *EPA-Corrected*, *24-Hour*). + +```text +Add structured logging extensions to library +Pin softprops/action-gh-release to commit SHA +Drop net8.0 multi-targeting from console project +Bump xunit.v3 from 3.2.2 to 3.3.0 +Clarify devcontainer setup steps in README +``` + +## Quantitative claims + +A quantitative claim in `README.md` (a count, a size, a version floor, a supported-platform list) +is verified against current code before it is written. When a doc number is derived from a code +constant, mark the dependency in a source-code comment so the next editor knows to update both. diff --git a/.agents/skills/comment-and-doc-style/references/carried-doc-references.md b/.agents/skills/comment-and-doc-style/references/carried-doc-references.md index 111d6e88..972c63f5 100644 --- a/.agents/skills/comment-and-doc-style/references/carried-doc-references.md +++ b/.agents/skills/comment-and-doc-style/references/carried-doc-references.md @@ -1,61 +1,61 @@ -# Carried Files Carry No Coordination References - -Full detail for the "Carried files reference no coordination machinery" rule in `SKILL.md`. Load -this when editing one of the carried files themselves, not when writing an ordinary repo-owned -doc. - -## Which files this governs - -`AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, `WORKFLOW.md`, `.github/copilot-instructions.md`, -the `repo-config/` and `spec/` files, and the carried `AUDIT.md`, the files the fleet carries -verbatim or at `intent` fidelity from the hub into every repo. This rule governs carried template -content only. A repo's own `README.md` and topical docs are its own content, never carried -verbatim, and this rule does not reach them. - -## What is banned - -Two 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 - naming where a file came from is exactly the derived-from framing the present-tense rule (in - `SKILL.md`'s "Markdown formatting" section) independently forbids. Where a carried file must - express a template-level behavior ("report a rule discrepancy upstream"), state the behavior - rather than the destination. The maintainer supplies the destination out of band. -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. - -## The two exceptions - -**The first exception is a verbatim section**, and `AGENTS.md` "Fleet Bootstrap" is why it exists. -That section's whole function is to name where the canonical rules live, for an agent in a -repository whose carried copies are stale, partial, or absent, which is exactly when no other file -present can say it. Its bytes are fixed fleet-wide, so a repository cannot edit the reference out -without failing the verbatim check instead, and a rule banning it would be unsatisfiable rather -than merely strict. The exception is scoped to the verbatim region and never leaks past it: the -same document's own prose, outside that region, is governed normally. A reference that reaches a -verbatim section is a defect in the canonical, fixed once at the source rather than reported -against every repository carrying it. - -**The second exception is a hub-hosted tool the reader is told to run**, which is a different kind -of reference. A rule naming a gate, a script, or a reference snippet the reader executes or copies -states an instruction rather than a provenance, and an instruction with no destination is -unfollowable, which is precisely how a pointer in carried text comes to read as decorative. The -test is whether the reference is something the reader *does* or something that *happened to this -file*: where the content came from stays out, what the reader runs stays in. Such a pointer names -the hub's canonical rather than this repository's provenance, so it is the hub's to keep resolving -and never a repository's to edit out or re-point at a local path. What is reached rather than -carried, and how, is `GOVERNANCE.md` "Hub-Hosted Tooling". In `AGENTS.md` and `GOVERNANCE.md` this -belongs in verbatim rule text, the same region the first exception already covers, so the whole -fleet reads one wording and no repository is asked to answer for a reference it did not write. - -## What is not a coordination reference - -**A contextually relevant link to a related project is expected, not banned.** Where another repo -is part of this repo's subject matter (the image that consumes this config, the builder that -generates this hardware, a library this depends on), link it normally. The test is whether the -link serves a reader of *this* repo's content, not whether the target happens to be in the fleet. - -This pairs with the present-tense rule: state the current shape, not a history of which repo it -came from. +# Carried Files Carry No Coordination References + +Full detail for the "Carried files reference no coordination machinery" rule in `SKILL.md`. Load +this when editing one of the carried files themselves, not when writing an ordinary repo-owned +doc. + +## Which files this governs + +`AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, `WORKFLOW.md`, `.github/copilot-instructions.md`, +the `repo-config/` and `spec/` files, and the carried `AUDIT.md`, the files the fleet carries +verbatim or at `intent` fidelity from the hub into every repo. This rule governs carried template +content only. A repo's own `README.md` and topical docs are its own content, never carried +verbatim, and this rule does not reach them. + +## What is banned + +Two 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 + naming where a file came from is exactly the derived-from framing the present-tense rule (in + `SKILL.md`'s "Markdown formatting" section) independently forbids. Where a carried file must + express a template-level behavior ("report a rule discrepancy upstream"), state the behavior + rather than the destination. The maintainer supplies the destination out of band. +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. + +## The two exceptions + +**The first exception is a verbatim section**, and `AGENTS.md` "Fleet Bootstrap" is why it exists. +That section's whole function is to name where the canonical rules live, for an agent in a +repository whose carried copies are stale, partial, or absent, which is exactly when no other file +present can say it. Its bytes are fixed fleet-wide, so a repository cannot edit the reference out +without failing the verbatim check instead, and a rule banning it would be unsatisfiable rather +than merely strict. The exception is scoped to the verbatim region and never leaks past it: the +same document's own prose, outside that region, is governed normally. A reference that reaches a +verbatim section is a defect in the canonical, fixed once at the source rather than reported +against every repository carrying it. + +**The second exception is a hub-hosted tool the reader is told to run**, which is a different kind +of reference. A rule naming a gate, a script, or a reference snippet the reader executes or copies +states an instruction rather than a provenance, and an instruction with no destination is +unfollowable, which is precisely how a pointer in carried text comes to read as decorative. The +test is whether the reference is something the reader *does* or something that *happened to this +file*: where the content came from stays out, what the reader runs stays in. Such a pointer names +the hub's canonical rather than this repository's provenance, so it is the hub's to keep resolving +and never a repository's to edit out or re-point at a local path. What is reached rather than +carried, and how, is `GOVERNANCE.md` "Hub-Hosted Tooling". In `AGENTS.md` and `GOVERNANCE.md` this +belongs in verbatim rule text, the same region the first exception already covers, so the whole +fleet reads one wording and no repository is asked to answer for a reference it did not write. + +## What is not a coordination reference + +**A contextually relevant link to a related project is expected, not banned.** Where another repo +is part of this repo's subject matter (the image that consumes this config, the builder that +generates this hardware, a library this depends on), link it normally. The test is whether the +link serves a reader of *this* repo's content, not whether the target happens to be in the fleet. + +This pairs with the present-tense rule: state the current shape, not a history of which repo it +came from. diff --git a/.agents/skills/comment-and-doc-style/references/line-endings.md b/.agents/skills/comment-and-doc-style/references/line-endings.md index 63f47774..7f54f939 100644 --- a/.agents/skills/comment-and-doc-style/references/line-endings.md +++ b/.agents/skills/comment-and-doc-style/references/line-endings.md @@ -1,132 +1,135 @@ -# Line Ending Policy - -Full detail for the "Line endings" rule in `SKILL.md`. Load this when choosing an ending for a -new file type, working in an operational (config) repo, pinning an extensionless executable, or -auditing a repo's endings, not for an ordinary content edit to an existing file (the SKILL.md -summary, preserve the existing ending and verify with a byte scan, covers that case). - -## The defaults - -- **`.editorconfig` sets the line ending.** `[*] end_of_line = crlf` - is the default, every file type is CRLF unless pinned otherwise, with LF pinned for the - execution-sensitive exceptions: `*.sh`, Dockerfiles, and any individual `.py` executed directly - via its shebang (pinned by path, for example `spec/validate.py`, vanilla `.py` stays CRLF, since - Python's universal newlines accept it and it is commonly edited on Windows). Only the LF - exceptions are declared, the redundant per-type CRLF rules are intentionally omitted. -- **`.gitattributes` mirrors it**: `* -text` (git stores the exact bytes committed and does not - normalize) plus the matching LF pins. -- **Both files are required together.** `.editorconfig` governs the editor, `.gitattributes` - governs git (checkout, commit, `--renormalize`). A repo missing either file, or whose - `.editorconfig` sets no global `end_of_line` default (for example declares it only under - `[*.md]`), accumulates files mixed between LF and CRLF, the exact failure these two files - prevent together. Carry both files whole (an inert `[*.cs]` block costs nothing in a non-.NET - repo), including the `*.sh text eol=lf` pin and any extensionless-script path pins. - -## Choosing an ending for a new file type - -CRLF is the default, since cross-platform editors on Windows produce it and it is harmless on -Linux for everything except shell. Use LF only when the type requires it or CRLF breaks how it is -consumed: executable scripts and shebangs (`*.sh`, s6, husky), Dockerfiles (CRLF breaks `RUN` -heredocs and line continuations), and tool-owned formats with a native LF ending (KiCad). -Non-workflow YAML stays CRLF, GitHub Actions' parser tolerates it (a repo also running yamllint -sets `new-lines: disable` to defer to `.editorconfig`). Workflow YAML -(`.github/workflows/*.{yml,yaml}`) is pinned LF in `.editorconfig`, because Dependabot and Actions -rewrite it with LF, so declaring LF keeps it consistent instead of mixed on every bump. This LF -class is not backed by a `.gitattributes` pin, git keeps `* -text`, and CI's `editorconfig-checker` -(EOL-only) catches a mismatch instead. - -Distinguish where a file is *consumed* from where it is *edited*, consumption on Linux alone does -not force LF. A config or pattern file consumed by a Linux tool stays CRLF when the tool tolerates -a trailing CR: `.dockerignore` and `.gitignore` are CRLF (their parsers strip the CR), and only a -*Dockerfile*, interpreted and broken by a CR in a `RUN` heredoc or line continuation, is LF. - -## Operational (config) repos - -The global default follows the consuming application's native platform, not the fleet CRLF -default. A config repo (registry `workflowModel: operational`) is a view into an application's -configuration directory, often the exact tree mounted into that app's container, so its files use -the ending the app itself reads and writes, and forcing the fleet CRLF default would fight the -app. Set the `[*] end_of_line` default to the app's native ending and record it in the registry -`lineEndings` field (`lf` or `crlf`): LF for a Linux-native app whose config lives in a Linux -container (ESPHome, Home Assistant, a devcontainer-only or HACS config), CRLF for a Windows-native -editor, for example Vantage InFusion config edited by Design Center on Windows. The -execution-sensitive LF pins (`*.sh`, Dockerfiles, workflow YAML) still apply on top, and -`.gitattributes` still mirrors the chosen default. This override is for operational repos only, -`release` repos keep the `[*] end_of_line = crlf` fleet default above. Do not re-normalize an -operational repo to the fleet default, that is exactly the over-normalization these per-repo -endings exist to prevent. - -**Mixed-consumer config: prefer to split by platform into single-platform repos, not one mixed -repo.** When a config repo would be consumed on two platforms (a Linux app plus a Windows-edited -subtree), the clean answer is a repo per consumer, each single-platform with its own -`lineEndings`. For example a controller config edited by a Windows-native editor lives in its own -CRLF repo, not as a subtree inside a Linux `lf` config repo. Fallback only if a subtree genuinely -cannot be split out: keep the global default at the primary consumer and pin the odd subtree with -an `.editorconfig` path override (for example `[<subtree>/**] end_of_line = crlf`) matching its -consumer. The global `* -text` in `.gitattributes` already preserves those bytes, no extra git pin -is needed. - -## Scripts and extensionless executables - -Must be LF, and pinned in `.gitattributes`, not just configured. A CRLF shebang -(`#!/usr/bin/env bash\r`) breaks execution. `.editorconfig` sets `[*.sh] = lf`, but that -extension-based rule does not match extensionless executables (s6 service scripts `run`/`up`/ -`finish`, husky or git hook scripts like `.husky/pre-commit`), and `* -text` enforces nothing, so -a broad normalization pass or an editor can silently flip them to CRLF. `.gitattributes` is the -enforcement layer: it carries `*.sh text eol=lf`, and any repo whose tooling ships extensionless -scripts adds the matching path pin (`Docker/s6-overlay/** text eol=lf` for s6 init, -`.husky/pre-commit text eol=lf` for husky hooks), so git holds them at LF on checkout and -`--renormalize`. This pin is mandatory for any repo that overrides s6 init, uses husky or git -hooks, or otherwise ships executable scripts. The same explicit-pin rule extends to tool-owned -file formats the base config does not key on: pin them to whatever ending the tool reads and -writes, for example KiCad project, footprint, and 3D files (`*.kicad_mod`, `*.kicad_sym`, -`*.step`), which KiCad writes LF. - -**Pair each such pin with a matching `.editorconfig` override**, since the git pin alone is not -enough, `.gitattributes` governs git while the editor follows `.editorconfig`, where the default -still applies to any file no extension rule covers. Give every extensionless executable an -editorconfig LF override beside its `.gitattributes` pin (`[.husky/pre-commit] end_of_line = lf`), -and for a byte-preserve data directory (downloaded or opaque source whose exact bytes the consumer -may depend on) disable all editor normalization, not just EOL: `[<dir>/*]` with `charset = unset`, -`end_of_line = unset`, `insert_final_newline = false`, `trim_trailing_whitespace = false` (`unset` -is EditorConfig's spec-defined special value that removes an inherited property). - -## Editing discipline - -- **New files**: create with the `.editorconfig`-mandated ending. -- **Editing an existing file**: preserve its current line endings, do not reflow them as a side - effect of a content change, even if the file is already non-compliant. A tool that rewrites a - file in text mode (a script, a bulk find/replace) can silently flip CRLF to LF and turn a - one-line change into a whole-file diff. After any programmatic edit, verify before staging: - `git diff --stat` should touch only the lines you changed, and a byte check should confirm the - expected ending. If a diff balloons to the whole file, the endings flipped, restore them and - re-stage. -- **Fixing a non-compliant file**: bring it to its `.editorconfig` ending as a deliberate change, - and prefer to isolate it in its own EOL-only commit so the churn is reviewable. When a broader - maintenance change has to normalize endings alongside content edits, call it out explicitly in - the commit or PR description and verify the content separately with - `git diff --ignore-cr-at-eol`. - -## Auditing - -Don't trust `file` or a naive `git ls-files --eol`. The authoritative check is a byte scan that -classifies by which endings are present: CRLF-only (every `\n` preceded by `\r`), LF-only (no -`\r`), or mixed (both forms present). Flag mixed explicitly rather than lumping it in with CRLF, -and skip binaries via a NUL-byte check. `file` mislabels some types (it reports a CRLF `.json` or -`.code-workspace` as plain "JSON text data" with no CRLF note), and `git ls-files --eol`'s `attr/` -column holds multiple tokens that shift naive field-splitting into false positives. Scope a -repo-wide audit to `git ls-files` plus `git ls-files --others --exclude-standard`, never a raw -`find`, which sweeps self-ignoring caches (`.mypy_cache`, `.artifacts`). - -Idempotent normalize: `b.replace(b"\r\n", b"\n").replace(b"\n", b"\r\n")`. A single within-line -string replace is EOL-safe, but a tool that inserts multiple lines or writes a new file into a -CRLF file must emit `\r\n`, since a naive `\n` insert creates mixed endings. `.code-workspace` is -JSONC (it has `//` comments), so strip them before JSON-parsing it. - -Editing CRLF files programmatically with a regex has a sharper trap: `.` matches `\r`, so a -captured line keeps its carriage return and rejoining with `\r\n` yields `CRCRLF`. A text-mode -rewrite has the mirror failure, silently flattening CRLF to LF. Prefer line-based edits -(`splitlines(keepends=True)`) or literal replacement over regex reassembly. In Python the -text-mode failure is the default: `Path.read_text()` decodes through universal newlines and -`write_text()` writes `\n` back, so a read-edit-write round trip flattens the whole file while the -edit itself looks correct. Pass `newline=''` to both, or work in bytes. +# Line Ending Policy + +Full detail for the "Line endings" rule in `SKILL.md`. Load this when choosing an ending for a +new file type, working in an operational (config) repo, pinning an extensionless executable, or +auditing a repo's endings, not for an ordinary content edit to an existing file (the SKILL.md +summary, preserve the existing ending and verify with a byte scan, covers that case). + +## The defaults + +- **`.editorconfig` sets the line ending.** `[*] end_of_line = lf` is the default, every file + type is LF unless pinned otherwise, with CRLF pinned for the one exception Windows requires: + `*.bat` and `*.cmd` (cmd.exe's line handling is unreliable on LF). Only the CRLF exception is + declared, the redundant per-type LF rules are intentionally omitted, since the default already + gives shell scripts, Dockerfiles, workflow YAML, `uv.lock`, and every shebang-executed `.py` + the ending they need without a path-specific pin. +- **`.gitattributes` mirrors the execution-sensitive classes**: `* -text` (git stores the exact + bytes committed and does not normalize) plus explicit `text eol=lf` pins for `*.sh`, Dockerfiles, + `uv.lock`, and the shebang-executed `.py` by-path list. Those pins are now redundant with the + `[*]` LF default above, since every path they name already gets LF from it, and they stay + regardless, as git-level enforcement independent of the editor (a re-normalization tool or a + CRLF-configured git client that does not consult `.editorconfig`). Do not add a new + `.gitattributes` pin for a path that only needs the default, `.editorconfig`'s `[*]` already + covers it. Add one only for a genuinely execution-sensitive path where losing LF breaks + execution, matching the existing classes. +- **Both files are required together.** `.editorconfig` governs the editor, `.gitattributes` + governs git (checkout, commit, `--renormalize`). A repo missing either file, or whose + `.editorconfig` sets no global `end_of_line` default (for example declares it only under + `[*.md]`), accumulates files mixed between LF and CRLF, the exact failure these two files + prevent together. Carry both files whole (an inert `[*.cs]` block costs nothing in a non-.NET + repo), including the `*.sh text eol=lf` pin and any extensionless-script path pins. + +## Choosing an ending for a new file type + +LF is the default, since it is what every tool, CI runner, and Dependabot bump produces, and +Windows GUI editors (VS Code, Visual Studio, Notepad, WordPad) all read and write it cleanly. Pin +CRLF only for a type Windows itself requires it for: `*.bat` and `*.cmd`. Everything else, +including YAML (workflow and non-workflow alike, no distinction needed now that both are LF), +`.gitignore`, `.dockerignore`, and a tool-owned format with a native LF ending (KiCad), takes the +`[*]` default with no override. + +## Operational (config) repos + +The global default follows the consuming application's native platform, not the fleet LF default. +A config repo (registry `workflowModel: operational`) is a view into an application's +configuration directory, often the exact tree mounted into that app's container, so its files use +the ending the app itself reads and writes, and forcing the fleet LF default would fight an app +that needs CRLF. Set the `[*] end_of_line` default to the app's native ending and record it in the +registry `lineEndings` field (`lf` or `crlf`): the field is still required for every operational +repo regardless of value, since a config repo's ending is a load-bearing decision tied to its +consuming app rather than something to leave implicit, exactly the reasoning this fleet-wide +default flip demonstrates. Most operational repos need no override today: a Linux-native app whose +config lives in a Linux container (ESPHome, Home Assistant, a devcontainer-only or HACS config) +already matches the new `lf` fleet default. CRLF is still declared for a genuinely Windows-native +editor, for example Vantage InFusion config edited by Design Center on Windows. `release` repos +keep the `[*] end_of_line = lf` fleet default above. Do not re-normalize an operational repo to +the fleet default, that is exactly the over-normalization these per-repo endings exist to +prevent, whichever direction the fleet default currently points. + +**Mixed-consumer config: prefer to split by platform into single-platform repos, not one mixed +repo.** When a config repo would be consumed on two platforms (a Linux app plus a Windows-edited +subtree), the clean answer is a repo per consumer, each single-platform with its own +`lineEndings`. For example a controller config edited by a Windows-native editor lives in its own +CRLF repo, not as a subtree inside a Linux `lf` config repo. Fallback only if a subtree genuinely +cannot be split out: keep the global default at the primary consumer and pin the odd subtree with +an `.editorconfig` path override (for example `[<subtree>/**] end_of_line = crlf`) matching its +consumer. The global `* -text` in `.gitattributes` already preserves those bytes, no extra git pin +is needed. + +## Scripts and extensionless executables + +Must be LF. A CRLF shebang (`#!/usr/bin/env bash\r`) breaks execution, and the `[*]` LF default +already covers an extensionless executable (s6 service scripts `run`/`up`/`finish`, husky or git +hook scripts like `.husky/pre-commit`) without needing a path-specific `.editorconfig` override, +since EditorConfig's `[*]` matches any filename including one with no extension. The remaining +risk is `.gitattributes`, whose `* -text` enforces nothing on its own, so a broad normalization +pass or a CRLF-configured git client can still flip such a file on checkout regardless of what +the editor writes. Pin it there: any repo whose tooling ships extensionless scripts adds the +matching path pin (`Docker/s6-overlay/** text eol=lf` for s6 init, `.husky/pre-commit text +eol=lf` for husky hooks), so git holds them at LF on checkout and `--renormalize` independent of +the editor default. This pin is mandatory for any repo that overrides s6 init, uses husky or git +hooks, or otherwise ships executable scripts. The same explicit-pin rule extends to tool-owned +file formats the base config does not key on: pin them to whatever ending the tool reads and +writes, for example KiCad project, footprint, and 3D files (`*.kicad_mod`, `*.kicad_sym`, +`*.step`), which KiCad writes LF (already the fleet default, so these need no override either +today, but stay pinned since a tool-owned ending is a fact about the tool, not a coincidence with +whatever the fleet default currently is). + +For a type that genuinely needs an ending the `[*]` default no longer supplies (a Windows-native +tool-owned format outside `.bat`/`.cmd`, or a byte-preserve data directory whose exact bytes the +consumer may depend on), still pair a `.gitattributes` pin with a matching `.editorconfig` +override, since the git pin alone is not enough there, `.gitattributes` governs git while the +editor follows `.editorconfig`. For a byte-preserve directory, disable all editor normalization, +not just EOL: `[<dir>/*]` with `charset = unset`, `end_of_line = unset`, `insert_final_newline = +false`, `trim_trailing_whitespace = false` (`unset` is EditorConfig's spec-defined special value +that removes an inherited property). + +## Editing discipline + +- **New files**: create with the `.editorconfig`-mandated ending. +- **Editing an existing file**: preserve its current line endings, do not reflow them as a side + effect of a content change, even if the file is already non-compliant. A tool that rewrites a + file in text mode (a script, a bulk find/replace) can silently flip CRLF to LF and turn a + one-line change into a whole-file diff. After any programmatic edit, verify before staging: + `git diff --stat` should touch only the lines you changed, and a byte check should confirm the + expected ending. If a diff balloons to the whole file, the endings flipped, restore them and + re-stage. +- **Fixing a non-compliant file**: bring it to its `.editorconfig` ending as a deliberate change, + and prefer to isolate it in its own EOL-only commit so the churn is reviewable. When a broader + maintenance change has to normalize endings alongside content edits, call it out explicitly in + the commit or PR description and verify the content separately with + `git diff --ignore-cr-at-eol`. + +## Auditing + +Don't trust `file` or a naive `git ls-files --eol`. The authoritative check is a byte scan that +classifies by which endings are present: CRLF-only (every `\n` preceded by `\r`), LF-only (no +`\r`), or mixed (both forms present). Flag mixed explicitly rather than lumping it in with CRLF, +and skip binaries via a NUL-byte check. `file` mislabels some types (it reports a CRLF `.json` or +`.code-workspace` as plain "JSON text data" with no CRLF note), and `git ls-files --eol`'s `attr/` +column holds multiple tokens that shift naive field-splitting into false positives. Scope a +repo-wide audit to `git ls-files` plus `git ls-files --others --exclude-standard`, never a raw +`find`, which sweeps self-ignoring caches (`.mypy_cache`, `.artifacts`). + +Idempotent normalize: `b.replace(b"\r\n", b"\n").replace(b"\n", b"\r\n")`. A single within-line +string replace is EOL-safe, but a tool that inserts multiple lines or writes a new file into a +CRLF file must emit `\r\n`, since a naive `\n` insert creates mixed endings. `.code-workspace` is +JSONC (it has `//` comments), so strip them before JSON-parsing it. + +Editing CRLF files programmatically with a regex has a sharper trap: `.` matches `\r`, so a +captured line keeps its carriage return and rejoining with `\r\n` yields `CRCRLF`. A text-mode +rewrite has the mirror failure, silently flattening CRLF to LF. Prefer line-based edits +(`splitlines(keepends=True)`) or literal replacement over regex reassembly. In Python the +text-mode failure is the default: `Path.read_text()` decodes through universal newlines and +`write_text()` writes `\n` back, so a read-edit-write round trip flattens the whole file while the +edit itself looks correct. Pass `newline=''` to both, or work in bytes. diff --git a/.agents/skills/comment-and-doc-style/references/markdown-links.md b/.agents/skills/comment-and-doc-style/references/markdown-links.md index e7251ccb..c3cd7e1a 100644 --- a/.agents/skills/comment-and-doc-style/references/markdown-links.md +++ b/.agents/skills/comment-and-doc-style/references/markdown-links.md @@ -1,64 +1,64 @@ -# Reference-Style Links - -Full detail for the "Markdown formatting" reference-style-links rule in `SKILL.md`. Load this -when actually authoring or reorganizing a Markdown file's link definitions, not for a small -in-place prose edit. - -## Where the rule applies - -Every Markdown file in the repo uses reference-style links only, except the four files that are -read one section at a time rather than end to end: `AGENTS.md`, `GOVERNANCE.md`, `OPERATIONS.md`, -and `.github/copilot-instructions.md`. Those keep inline `[text](uri)` links, since a reader -jumping straight to one section needs the target to resolve where it is, while a definition parked -at the bottom of the file is never reached. The exception is that closed list of four files, never -a category to argue from case by case. Every other Markdown file follows the rule regardless of -its audience. - -## The definition block - -Every URI, an internal path, an anchor, an external URL, or a shield image, is defined at the -bottom of the file, split into groups by type under an HTML-comment header, for example: - -```markdown -<!-- Shields --> - -[license-shield]: https://img.shields.io/... - -<!-- Repo --> - -[governance]: ./GOVERNANCE.md -[governance-branching-model]: ./GOVERNANCE.md#branching-model - -<!-- External --> - -[markdownlint-cli2]: https://github.com/DavidAnson/markdownlint-cli2 -``` - -Within a group, definitions are alphabetized by **reference name alone**, the text inside the -brackets, never by the whole definition line. Where one name is a prefix of another, the shorter -one sorts first: `[governance]` above `[governance-branching-model]`, `[repo-config]` above -`[repo-config-settings]`. Sorting the full line instead inverts every such pair, because `-` -precedes `]` in byte order, so the two readings disagree on exactly the names a reader looks up -together, and a plain `sort -c` over the block passes on the inverted order regardless. - -## Naming a reference - -Reference names are contextual and encode both the target and its group: - -- `foo-shield` for a shield image -- `foo-link` for an external URL -- a bare `foo` for a local path or anchor - -For example `[license-shield]`, `[releases-link]`, `[repo-config]`. Never a numeric name (`[1]`) -and never an opaque one. - -## Mechanics - -- No inline `[text](uri)` targets in prose, in any file outside the four-file exception above. -- **A URL inside a fenced code block stays inline.** Reference links do not resolve inside a code - block, so do not extract it there, and exclude fenced code from any link-integrity check - (bracket literals like `["a", "b"]` otherwise read as undefined references). -- **Removing a link also removes its reference definition.** An orphaned definition fails the - no-unused-defs rule. -- The one exception to "no inline links" is the Table of Contents, whose entries stay inline - anchor links, since the ToC extension generates them that way and they are never hand-edited. +# Reference-Style Links + +Full detail for the "Markdown formatting" reference-style-links rule in `SKILL.md`. Load this +when actually authoring or reorganizing a Markdown file's link definitions, not for a small +in-place prose edit. + +## Where the rule applies + +Every Markdown file in the repo uses reference-style links only, except the four files that are +read one section at a time rather than end to end: `AGENTS.md`, `GOVERNANCE.md`, `OPERATIONS.md`, +and `.github/copilot-instructions.md`. Those keep inline `[text](uri)` links, since a reader +jumping straight to one section needs the target to resolve where it is, while a definition parked +at the bottom of the file is never reached. The exception is that closed list of four files, never +a category to argue from case by case. Every other Markdown file follows the rule regardless of +its audience. + +## The definition block + +Every URI, an internal path, an anchor, an external URL, or a shield image, is defined at the +bottom of the file, split into groups by type under an HTML-comment header, for example: + +```markdown +<!-- Shields --> + +[license-shield]: https://img.shields.io/... + +<!-- Repo --> + +[governance]: ./GOVERNANCE.md +[governance-branching-model]: ./GOVERNANCE.md#branching-model + +<!-- External --> + +[markdownlint-cli2]: https://github.com/DavidAnson/markdownlint-cli2 +``` + +Within a group, definitions are alphabetized by **reference name alone**, the text inside the +brackets, never by the whole definition line. Where one name is a prefix of another, the shorter +one sorts first: `[governance]` above `[governance-branching-model]`, `[repo-config]` above +`[repo-config-settings]`. Sorting the full line instead inverts every such pair, because `-` +precedes `]` in byte order, so the two readings disagree on exactly the names a reader looks up +together, and a plain `sort -c` over the block passes on the inverted order regardless. + +## Naming a reference + +Reference names are contextual and encode both the target and its group: + +- `foo-shield` for a shield image +- `foo-link` for an external URL +- a bare `foo` for a local path or anchor + +For example `[license-shield]`, `[releases-link]`, `[repo-config]`. Never a numeric name (`[1]`) +and never an opaque one. + +## Mechanics + +- No inline `[text](uri)` targets in prose, in any file outside the four-file exception above. +- **A URL inside a fenced code block stays inline.** Reference links do not resolve inside a code + block, so do not extract it there, and exclude fenced code from any link-integrity check + (bracket literals like `["a", "b"]` otherwise read as undefined references). +- **Removing a link also removes its reference definition.** An orphaned definition fails the + no-unused-defs rule. +- The one exception to "no inline links" is the Table of Contents, whose entries stay inline + anchor links, since the ToC extension generates them that way and they are never hand-edited. diff --git a/.agents/skills/copilot-instructions-keeper/SKILL.md b/.agents/skills/copilot-instructions-keeper/SKILL.md index e16f76c5..b9d0154d 100644 --- a/.agents/skills/copilot-instructions-keeper/SKILL.md +++ b/.agents/skills/copilot-instructions-keeper/SKILL.md @@ -1,95 +1,95 @@ ---- -name: copilot-instructions-keeper -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. ---- - -# Copilot Instructions Keeper - -## Why this exists - -`.github/copilot-instructions.md` is read directly by the GitHub Copilot review bot, which -cannot read a `SKILL.md`. Unlike other carried governance content, its rule text is never -pointer-ized into a skill, this file always stays fully intact in every repo that carries it. -This skill is about maintaining that file's carried copy, not a substitute for it. - -`spec/files.json` declares it `intent` fidelity, `whole: true`, covering three named sections -(`Commit Messages and Pull Request Titles`, `Reviewing Carried Fleet Content`, `GitHub Copilot -Review Runbook`), with `<owner>`, `<repo>`, and `<N>` placeholders filled per repo. **The fleet -audit checks an `intent` file for file presence and each named section's heading, never for -content drift inside a section.** A section that is present but has fallen out of date against -the hub, the exact gap this skill exists to catch, produces no finding anywhere in the mechanical -audit. Noticing that has to happen in a live session like this one. - -## The one thing this file has that others don't: repo-local ledger entries - -The file's own "Disproved Claims" section states its rule plainly. **The section's shape and -governing rules are carried, but its entries are not.** Each entry records a finding that was -raised against this specific repository and disproved against this repository's code at a named -revision. A repository carrying a copy of this file carries the shape and rules, deletes any -entry whose subject it does not hold, and records what it has proved for itself. - -This means a blind re-vendor of the hub's canonical `.github/copilot-instructions.md` over a downstream -repo's copy is wrong in both directions: - -- Copying the hub's own "Disproved Claims" entries (about `ProjectTemplate` itself) into a - downstream repo attaches proofs about code that repo does not carry. -- Overwriting a downstream repo's copy wholesale deletes any entries that repo itself has earned, - a live disproof, run against that repo's own tree, thrown away with no record. - -**Before touching this file in any repo other than the hub itself:** - -1. Read the current "Disproved Claims" section in that repo's copy, if it has one, and preserve - every entry that names a file or behavior that repo actually carries. -2. Update everything else, the runbook mechanics, the three named sections, the rule text, to - match the hub canonical. -3. Never carry the hub's own repo-specific "Disproved Claims" entries downstream. They name - `ProjectTemplate`'s own files and revisions, not the target repo's. -4. If in doubt whether an entry is still valid for the current tree, treat it per the guard skill - below rather than guessing. - -This is the same failure class [`carried-instruction-file-guard`](../carried-instruction-file-guard/SKILL.md) -exists to stop for `AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, and `WORKFLOW.md`: a -routine-sounding overwrite silently deleting content that is not a stale copy of the hub. Run -that skill's distinctive-phrase probe against this file too before any full-file replace. It is -not in that skill's own file list because its failure mode, ledger entries rather than fleet -rules, is specific enough to warrant its own skill, but the underlying discipline, probe before -overwrite, give a local addition a destination rather than deleting it, is the same. - -## Checking a repo's copy for drift - -1. Fetch the hub (`github.com/ptr727/ProjectTemplate`) `main` branch fresh. A stale local clone - answers confidently instead of failing. -2. Compare the target repo's `.github/copilot-instructions.md` against the hub's, section by - section, at **intent** fidelity, judged by meaning, not by byte match. A content-identical - file with different `<owner>`/`<repo>` placeholder fills is current, not drifted. -3. Read the "Disproved Claims" section separately from the rest. Judge its **shape and rules** - against the hub, and judge its **entries** only against what that repo itself carries (see - above), never against the hub's own entries. -4. Report what is actually stale (a runbook mechanic that changed, a rule that moved, a new - section) versus what only looks different because it is correctly repo-specific. - -## Carrying it fresh, new repo or full resync - -Follow `RESYNC.md`'s general apply order for carried files, with the ledger rule above applied at -the point this file is touched: carry the hub's current rule text and runbook mechanics, keep the -target repo's own "Disproved Claims" entries (if any existed pre-resync) rather than replacing -them with the hub's, and start a new repo's ledger empty rather than seeded from the hub's own -proofs. - -## What this skill does not cover - -Content-style rules for other carried files (`AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, -`WORKFLOW.md`) are `carried-instruction-file-guard`'s job. The review-loop contract this file's -runbook implements, the merge gate, triage, escalation, is `pr-review-conduct`'s job. This skill -is narrowly about keeping this one file's carried copy correct. +--- +name: copilot-instructions-keeper +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. +--- + +# Copilot Instructions Keeper + +## Why this exists + +`.github/copilot-instructions.md` is read directly by the GitHub Copilot review bot, which +cannot read a `SKILL.md`. Unlike other carried governance content, its rule text is never +pointer-ized into a skill, this file always stays fully intact in every repo that carries it. +This skill is about maintaining that file's carried copy, not a substitute for it. + +`spec/files.json` declares it `intent` fidelity, `whole: true`, covering three named sections +(`Commit Messages and Pull Request Titles`, `Reviewing Carried Fleet Content`, `GitHub Copilot +Review Runbook`), with `<owner>`, `<repo>`, and `<N>` placeholders filled per repo. **The fleet +audit checks an `intent` file for file presence and each named section's heading, never for +content drift inside a section.** A section that is present but has fallen out of date against +the hub, the exact gap this skill exists to catch, produces no finding anywhere in the mechanical +audit. Noticing that has to happen in a live session like this one. + +## The one thing this file has that others don't: repo-local ledger entries + +The file's own "Disproved Claims" section states its rule plainly. **The section's shape and +governing rules are carried, but its entries are not.** Each entry records a finding that was +raised against this specific repository and disproved against this repository's code at a named +revision. A repository carrying a copy of this file carries the shape and rules, deletes any +entry whose subject it does not hold, and records what it has proved for itself. + +This means a blind re-vendor of the hub's canonical `.github/copilot-instructions.md` over a downstream +repo's copy is wrong in both directions: + +- Copying the hub's own "Disproved Claims" entries (about `ProjectTemplate` itself) into a + downstream repo attaches proofs about code that repo does not carry. +- Overwriting a downstream repo's copy wholesale deletes any entries that repo itself has earned, + a live disproof, run against that repo's own tree, thrown away with no record. + +**Before touching this file in any repo other than the hub itself:** + +1. Read the current "Disproved Claims" section in that repo's copy, if it has one, and preserve + every entry that names a file or behavior that repo actually carries. +2. Update everything else, the runbook mechanics, the three named sections, the rule text, to + match the hub canonical. +3. Never carry the hub's own repo-specific "Disproved Claims" entries downstream. They name + `ProjectTemplate`'s own files and revisions, not the target repo's. +4. If in doubt whether an entry is still valid for the current tree, treat it per the guard skill + below rather than guessing. + +This is the same failure class [`carried-instruction-file-guard`](../carried-instruction-file-guard/SKILL.md) +exists to stop for `AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, and `WORKFLOW.md`: a +routine-sounding overwrite silently deleting content that is not a stale copy of the hub. Run +that skill's distinctive-phrase probe against this file too before any full-file replace. It is +not in that skill's own file list because its failure mode, ledger entries rather than fleet +rules, is specific enough to warrant its own skill, but the underlying discipline, probe before +overwrite, give a local addition a destination rather than deleting it, is the same. + +## Checking a repo's copy for drift + +1. Fetch the hub (`github.com/ptr727/ProjectTemplate`) `main` branch fresh. A stale local clone + answers confidently instead of failing. +2. Compare the target repo's `.github/copilot-instructions.md` against the hub's, section by + section, at **intent** fidelity, judged by meaning, not by byte match. A content-identical + file with different `<owner>`/`<repo>` placeholder fills is current, not drifted. +3. Read the "Disproved Claims" section separately from the rest. Judge its **shape and rules** + against the hub, and judge its **entries** only against what that repo itself carries (see + above), never against the hub's own entries. +4. Report what is actually stale (a runbook mechanic that changed, a rule that moved, a new + section) versus what only looks different because it is correctly repo-specific. + +## Carrying it fresh, new repo or full resync + +Follow `RESYNC.md`'s general apply order for carried files, with the ledger rule above applied at +the point this file is touched: carry the hub's current rule text and runbook mechanics, keep the +target repo's own "Disproved Claims" entries (if any existed pre-resync) rather than replacing +them with the hub's, and start a new repo's ledger empty rather than seeded from the hub's own +proofs. + +## What this skill does not cover + +Content-style rules for other carried files (`AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, +`WORKFLOW.md`) are `carried-instruction-file-guard`'s job. The review-loop contract this file's +runbook implements, the merge gate, triage, escalation, is `pr-review-conduct`'s job. This skill +is narrowly about keeping this one file's carried copy correct. diff --git a/.agents/skills/dotnet-codestyle/SKILL.md b/.agents/skills/dotnet-codestyle/SKILL.md index 8cc485fc..69efa644 100644 --- a/.agents/skills/dotnet-codestyle/SKILL.md +++ b/.agents/skills/dotnet-codestyle/SKILL.md @@ -1,359 +1,359 @@ ---- -name: dotnet-codestyle -description: >- - Governs C#/.NET code style for ptr727/ProjectTemplate fleet repos: 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. Use this whenever writing, reviewing, or editing a .cs file, a .csproj, - Directory.Build.props, or Directory.Packages.props, whenever choosing where to suppress an - analyzer diagnostic, whenever a NuGet library needs to log without depending on Serilog - directly, or whenever writing or reviewing an xUnit test. Triggers even when the task looks - like a small local fix ("just silence this warning", "add a quick log line", "bump a package - version"), because the zero-warnings policy, the suppression-scope order, the central-package- - management rule, and the library/application logging split are each easy to violate one file at - a time without the pattern ever showing up as a single obvious diff. Applies only to a repo's - .NET side, a repo with no .NET projects has no use for this Skill. ---- - -# .NET Codestyle - -## Why this exists - -This is the .NET-specific half of the fleet's code style guide, kept in one place instead of -re-derived per repo or per session. CODESTYLE.md's General section still owns the rules every -language shares (clean-compile verification as a concept, the suppression-scope order, tooling -casing in prose), this Skill is everything specific to a C#/.NET project on top of that: the -concrete `.NET Format` task chain, the analyzer configuration that makes the zero-warnings policy -real, and the language, naming, logging, and testing conventions. - -## Build requirements - -### Zero warnings policy - -All builds must complete without warnings, enforced three ways: - -- **The `.NET Format` clean-compile task.** It chains `CSharpier Format` -> `.NET Build` -> - `dotnet format style --verify-no-changes`. A repo carries those three task definitions in its - own `.vscode/tasks.json`, matching the canonical `vscode-tasks.json` snippet at - `github.com/ptr727/ProjectTemplate/blob/main/catalog/snippets/configs/vscode-tasks.json`. Run - the `.NET Format` task after any code change, before commit. To run it natively instead, - reproduce that exact task chain (`CSharpier Format`, then `.NET Build`, then - `dotnet format style --verify-no-changes --severity=info --verbosity=detailed`) without dropping - or loosening any argument, reading it from that same canonical snippet. Bare `dotnet format` - alone, skipping CSharpier or the build, is not sufficient. -- **Analyzer configuration.** `<EnableNETAnalyzers>true</EnableNETAnalyzers>` with - `<AnalysisLevel>latest-all</AnalysisLevel>` and `<AnalysisMode>All</AnalysisMode>` (the full - analyzer set), plus `<TreatWarningsAsErrors>true</TreatWarningsAsErrors>`, so any diagnostic - surfaced as a warning fails the build and must be fixed or deliberately suppressed at the - narrowest scope that fits (see Analyzer suppressions below), never left to accumulate. -- **CI lint backstop.** CI runs the clean-compile checks on every PR as the authoritative gate. - Git hooks are optional, and a repo may wire a local runner (Husky.Net, with `dotnet husky run` - as a style step) for pre-commit enforcement, but CI is the gate that matters. - -**A new port is not a license to silence diagnostics.** Brownfield or just-ported status never -justifies relaxing analyzer severities or muting newly surfaced warnings. Fix them. (The only -brownfield allowance in the fleet is the one-time git-signing / line-ending migration described in -GOVERNANCE.md and README.md, which has nothing to do with code analysis.) - -### Central build and package configuration - -Shared MSBuild configuration is centralized at the repository root, never duplicated per project: - -- **`Directory.Build.props`** carries the properties every project shares: the analyzer set and - `TreatWarningsAsErrors` from the zero-warnings policy above, plus `LangVersion`, - `TargetFramework` where uniform, and any repo-wide build metadata. A `.csproj` carries only what - is genuinely project-specific (`OutputType`, `IsPackable`, project references). -- **`Directory.Packages.props`** owns central package management: it sets - `ManagePackageVersionsCentrally` to `true` (in this file, not `Directory.Build.props`) and - declares every dependency version once as a `PackageVersion` item, so a `.csproj`'s - `PackageReference` items are versionless. One file to review on a bump, one Dependabot surface, - no version skew between projects. - -A repo whose projects still carry per-project analyzer settings or versioned `PackageReference` -items is drifted, move the shared property or version up to the root file rather than editing it -in place. - -### Build tasks - -Run these from VS Code's task runner (Terminal -> Run Task) or an agent's task-running tool. The -three clean-compile tasks are carried verbatim, and a repo adds its own convenience tasks (tool -updates, dependency upgrades, benchmarks) on top: - -- `.NET Build`: build with diagnostic verbosity *(clean-compile)* -- `CSharpier Format`: auto-format code with CSharpier *(clean-compile)* -- `.NET Format`: run CSharpier and build, then verify formatting and style with - `--verify-no-changes` *(clean-compile, the task to run after edits)* - -## Tooling and editor - -- **CSharpier** is the primary code formatter, invoked by the `CSharpier Format` task or - `dotnet csharpier format --log-level=debug .`. -- **`dotnet format`** verifies style: - `dotnet format style --verify-no-changes --severity=info --verbosity=detailed`. -- **`dotnet-outdated-tool`** checks for dependency updates, and Nerdbank.GitVersioning owns - version management. -- CI is the authoritative lint backstop. Local pre-commit hooks are optional, wire Husky.Net (or - another runner) if you want local enforcement. -- **Required VS Code extensions**: CSharpier, markdownlint, CSpell. Use the workspace settings - without overrides. - -## Coding standards and conventions - -Code snippets below are illustrative examples only, replace namespaces and types to match your -project. - -### C# language features - -1. **File-scoped namespaces**: - - ```csharp - namespace Example.Project.Library; - ``` - -2. **Nullable reference types**: enabled (`<Nullable>enable</Nullable>`), use nullable annotations - appropriately, use `required` for mandatory properties. -3. **Modern C# features**: prefer modern language constructs, primary constructors when - appropriate, top-level statements for console apps, pattern matching over traditional checks, - collection expressions when types loosely match, extension methods (the classic - `this`-parameter form or an `extension(<receiver>) { ... }` block on C# 14+), implicit object - creation when the type is apparent, range and index operators. -4. **Expression-bodied members**: use for applicable methods, properties, accessors, operators, - lambdas, local functions. -5. **`var` keyword**: do NOT use `var`, always use explicit types: - - ```csharp - // Correct - int count = 42; - string name = "test"; - - // Incorrect - var count = 42; - var name = "test"; - ``` - -### Naming conventions - -1. **Private fields**: underscore prefix with camelCase: - - ```csharp - private readonly HttpClient _httpClient; - private int _counter; - ``` - -2. **Static fields**: `s_` prefix with camelCase: - - ```csharp - private static int s_instanceCount; - ``` - -3. **Constants**: PascalCase: - - ```csharp - private const int MaxRetries = 3; - ``` - -### Code structure - -1. **Global usings**: use `GlobalUsings.cs` for common namespaces: - - ```csharp - global using System; - global using System.Net.Http; - global using System.Threading.Tasks; - global using Microsoft.Extensions.Logging; - ``` - -2. **Usings placement**: outside the namespace, sorted with `System` directives first: - - ```csharp - using System.CommandLine; - using System.Runtime.CompilerServices; - using Example.Project.Library; - - namespace Example.Project.Console; - ``` - -3. **Braces**: Allman style: - - ```csharp - public void Method() - { - if (condition) - { - // code - } - } - ``` - -4. **Indentation**: C# files 4 spaces, XML/csproj files 2 spaces, YAML files 2 spaces, JSON files - 4 spaces. -5. **Line endings**: not specified here, governed per repo by `.editorconfig` / `.gitattributes` - per GOVERNANCE.md's "Line Endings" section. -6. **`#region`**: do not use regions, prefer logical file/folder/namespace organization. -7. **Member ordering (StyleCop SA1201)**: const -> static readonly -> static fields -> instance - readonly fields -> instance fields -> constructors -> public (events -> properties -> indexers - -> methods -> operators) -> non-public in same order -> nested types. - -### Comments and documentation - -XML documentation is on: `<GenerateDocumentationFile>true</GenerateDocumentationFile>`, and -missing XML comments for public APIs are suppressed in `.editorconfig`. Every public surface must -still be documented: a single-line summary, additional details in remarks, documented input -parameters, return values, exceptions, and crefs. - -```csharp -/// <summary> -/// Example of a single line summary. -/// </summary> -/// <remarks> -/// Additional important details about usage. -/// Multiple lines if needed. -/// </remarks> -/// <param name="category"> -/// The quote category to request -/// </param> -/// <param name="cancellationToken"> -/// A <see cref="System.Threading.CancellationToken"/> that can be used to cancel the request. -/// </param> -/// <returns> -/// A <see cref="string"/> containing the quote text. -/// </returns> -/// <exception cref="System.ArgumentException"> -/// Thrown when <paramref name="category"/> is not a supported value. -/// </exception> -public async Task<string> GetQuoteOfTheDayAsync(string category, CancellationToken cancellationToken) {} -``` - -## Analyzer suppressions (.NET) - -CODESTYLE.md's General section sets the suppression-scope order fleet-wide: narrowest scope first, -symbol-scoped before project-scoped before repo-wide, and only for a genuine false-positive or a -deliberate, documented exception, never a blanket relaxation to get a brownfield port to build. -The .NET mechanics, narrowest first: - -- **Never use `#pragma warning disable`** to silence an analyzer. -- **Symbol-scoped**: a `[System.Diagnostics.CodeAnalysis.SuppressMessage(...)]` attribute with a - `Justification`, on the specific member or type: - - ```csharp - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Design", - "CA1034:Nested types should not be visible", - Justification = "https://github.com/dotnet/sdk/issues/51681" - )] - ``` - -- **Project-scoped** (e.g. a test project): a `dotnet_diagnostic.<RULE>.severity` entry in that - project's own `.editorconfig`, with a comment explaining why. -- **Repo-wide**: a `dotnet_diagnostic.<RULE>.severity` entry in the root `.editorconfig`, only - when the rule is genuinely not applicable to any project. Relaxing a batch of `CA*` rules (or - `dotnet_analyzer_diagnostic.severity`) to push a brownfield port through the build is exactly - what this forbids. - -## Error handling and logging - -1. **Structured logging**: use structured message templates. Serilog is the application's concrete - backend, and a library never references it directly (see item 2): - - ```csharp - logger.LogError(exception, "{Function}", function); - ``` - -2. **Libraries log through abstractions, never a concrete backend.** A NuGet library depends only - on `Microsoft.Extensions.Logging.Abstractions` and exposes an `ILoggerFactory` seam: a settable - global factory defaulting to `NullLoggerFactory.Instance` (fallback `NullLogger.Instance`) with - `SetFactory`/`TrySetFactory`, and/or an `ILoggerFactory`/`ILogger` parameter in its API. It - must not reference Serilog or any sink, which would force a logging framework on every consumer - and drag in AOT-incompatible dependencies. The consuming application owns the concrete logger - (Serilog is fine there), bridges it to `ILoggerFactory` (e.g. `SerilogLoggerFactory` from - `Serilog.Extensions.Logging`), and injects it. Reference pattern: a `LogOptions` seam in the - library, against which the consuming CLI builds the Serilog-backed factory and injects it via - `LogOptions.SetFactory`. -3. **CallerMemberName**: use for automatic function name tracking: - - ```csharp - public bool LogAndPropagate( - Exception exception, - [CallerMemberName] string function = "unknown" - ) - ``` - -4. **Logger extensions**: use `Extensions.cs` for logger and other extension methods: - - ```csharp - extension(ILogger logger) - { - public bool LogAndPropagate(Exception exception, ...) { } - } - ``` - -5. **Exceptions**: do not swallow exceptions, either log and rethrow or translate to a - domain-specific exception. - -## Code patterns - -1. **Guard clauses**: prefer early returns for validation and error handling. -2. **Async all the way**: avoid blocking calls (`.Result`, `.Wait()`), use `async`/`await`. -3. **Cancellation tokens**: accept `CancellationToken` as the last parameter and pass it through. -4. **ConfigureAwait**: in library code, use `ConfigureAwait(false)` unless context is required. Do - not call `ConfigureAwait(false)` in xUnit tests (see xUnit1030). -5. **Disposables**: use `await using` for async disposables, prefer `using` declarations. -6. **LINQ vs loops**: use LINQ for clarity, loops for hot paths or allocations. -7. **HTTP**: reuse `HttpClient` via factory, never per-request instantiation. -8. **Collections**: prefer `IReadOnlyList<T>`/`IReadOnlyCollection<T>` for public APIs. -9. **Immutability**: prefer immutable records, use init-only setters when records are not - suitable, and prefer immutable or frozen collections for read-only data. -10. **Exceptions as control flow**: avoid using exceptions for expected flow. -11. **Sealing classes**: seal classes that are not designed for inheritance. -12. **Lazy initialization**: use `Lazy<T>` for static, thread-safe instantiation (e.g. a logger - factory, an HTTP factory). - -## Testing conventions - -1. **Framework**: xUnit v3 or later (the `xunit.v3` package, never the legacy v2 `xunit` package) - with AwesomeAssertions for every assertion. Native xUnit asserts (`Assert.Equal`, - `Assert.True`, ...) are not allowed, use the fluent `.Should()` API. Dynamic test skipping - (`Assert.Skip`, `Assert.SkipWhen`) is control flow, not an assertion, and stays native: - - ```csharp - [Fact] - public void MethodName_Scenario_ExpectedBehavior() - { - // Arrange - int expected = 42; - - // Act - int actual = GetValue(); - - // Assert - actual.Should().Be(expected); - } - ``` - -2. **Organization**: Arrange-Act-Assert pattern. -3. **Naming**: descriptive names with underscores. -4. **Theory tests**: use `[Theory]` with `[InlineData]`. - -## Project configuration - -1. **Target framework**: .NET 10.0 (`<TargetFramework>net10.0</TargetFramework>`). -2. **AOT compatibility**: `<IsAotCompatible>true</IsAotCompatible>`, - `<VerifyReferenceAotCompatibility>true</VerifyReferenceAotCompatibility>`. -3. **Assembly information**: use semantic versioning, include SourceLink - (`<PublishRepositoryUrl>true</PublishRepositoryUrl>`), embed untracked sources - (`<EmbedUntrackedSources>true</EmbedUntrackedSources>`). -4. **Internal visibility**: use `InternalsVisibleTo` for test and benchmark access (adapt the - project names to your repo's test/benchmark projects): - - ```xml - <ItemGroup> - <InternalsVisibleTo Include="YourBenchmarkProject" /> - <InternalsVisibleTo Include="YourTestProject" /> - </ItemGroup> - ``` - -## Best practices - -All changes go through pull requests. +--- +name: dotnet-codestyle +description: >- + Governs C#/.NET code style for ptr727/ProjectTemplate fleet repos: 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. Use this whenever writing, reviewing, or editing a .cs file, a .csproj, + Directory.Build.props, or Directory.Packages.props, whenever choosing where to suppress an + analyzer diagnostic, whenever a NuGet library needs to log without depending on Serilog + directly, or whenever writing or reviewing an xUnit test. Triggers even when the task looks + like a small local fix ("just silence this warning", "add a quick log line", "bump a package + version"), because the zero-warnings policy, the suppression-scope order, the central-package- + management rule, and the library/application logging split are each easy to violate one file at + a time without the pattern ever showing up as a single obvious diff. Applies only to a repo's + .NET side, a repo with no .NET projects has no use for this Skill. +--- + +# .NET Codestyle + +## Why this exists + +This is the .NET-specific half of the fleet's code style guide, kept in one place instead of +re-derived per repo or per session. CODESTYLE.md's General section still owns the rules every +language shares (clean-compile verification as a concept, the suppression-scope order, tooling +casing in prose), this Skill is everything specific to a C#/.NET project on top of that: the +concrete `.NET Format` task chain, the analyzer configuration that makes the zero-warnings policy +real, and the language, naming, logging, and testing conventions. + +## Build requirements + +### Zero warnings policy + +All builds must complete without warnings, enforced three ways: + +- **The `.NET Format` clean-compile task.** It chains `CSharpier Format` -> `.NET Build` -> + `dotnet format style --verify-no-changes`. A repo carries those three task definitions in its + own `.vscode/tasks.json`, matching the canonical `vscode-tasks.json` snippet at + `github.com/ptr727/ProjectTemplate/blob/main/catalog/snippets/configs/vscode-tasks.json`. Run + the `.NET Format` task after any code change, before commit. To run it natively instead, + reproduce that exact task chain (`CSharpier Format`, then `.NET Build`, then + `dotnet format style --verify-no-changes --severity=info --verbosity=detailed`) without dropping + or loosening any argument, reading it from that same canonical snippet. Bare `dotnet format` + alone, skipping CSharpier or the build, is not sufficient. +- **Analyzer configuration.** `<EnableNETAnalyzers>true</EnableNETAnalyzers>` with + `<AnalysisLevel>latest-all</AnalysisLevel>` and `<AnalysisMode>All</AnalysisMode>` (the full + analyzer set), plus `<TreatWarningsAsErrors>true</TreatWarningsAsErrors>`, so any diagnostic + surfaced as a warning fails the build and must be fixed or deliberately suppressed at the + narrowest scope that fits (see Analyzer suppressions below), never left to accumulate. +- **CI lint backstop.** CI runs the clean-compile checks on every PR as the authoritative gate. + Git hooks are optional, and a repo may wire a local runner (Husky.Net, with `dotnet husky run` + as a style step) for pre-commit enforcement, but CI is the gate that matters. + +**A new port is not a license to silence diagnostics.** Brownfield or just-ported status never +justifies relaxing analyzer severities or muting newly surfaced warnings. Fix them. (The only +brownfield allowance in the fleet is the one-time git-signing / line-ending migration described in +GOVERNANCE.md and README.md, which has nothing to do with code analysis.) + +### Central build and package configuration + +Shared MSBuild configuration is centralized at the repository root, never duplicated per project: + +- **`Directory.Build.props`** carries the properties every project shares: the analyzer set and + `TreatWarningsAsErrors` from the zero-warnings policy above, plus `LangVersion`, + `TargetFramework` where uniform, and any repo-wide build metadata. A `.csproj` carries only what + is genuinely project-specific (`OutputType`, `IsPackable`, project references). +- **`Directory.Packages.props`** owns central package management: it sets + `ManagePackageVersionsCentrally` to `true` (in this file, not `Directory.Build.props`) and + declares every dependency version once as a `PackageVersion` item, so a `.csproj`'s + `PackageReference` items are versionless. One file to review on a bump, one Dependabot surface, + no version skew between projects. + +A repo whose projects still carry per-project analyzer settings or versioned `PackageReference` +items is drifted, move the shared property or version up to the root file rather than editing it +in place. + +### Build tasks + +Run these from VS Code's task runner (Terminal -> Run Task) or an agent's task-running tool. The +three clean-compile tasks are carried verbatim, and a repo adds its own convenience tasks (tool +updates, dependency upgrades, benchmarks) on top: + +- `.NET Build`: build with diagnostic verbosity *(clean-compile)* +- `CSharpier Format`: auto-format code with CSharpier *(clean-compile)* +- `.NET Format`: run CSharpier and build, then verify formatting and style with + `--verify-no-changes` *(clean-compile, the task to run after edits)* + +## Tooling and editor + +- **CSharpier** is the primary code formatter, invoked by the `CSharpier Format` task or + `dotnet csharpier format --log-level=debug .`. +- **`dotnet format`** verifies style: + `dotnet format style --verify-no-changes --severity=info --verbosity=detailed`. +- **`dotnet-outdated-tool`** checks for dependency updates, and Nerdbank.GitVersioning owns + version management. +- CI is the authoritative lint backstop. Local pre-commit hooks are optional, wire Husky.Net (or + another runner) if you want local enforcement. +- **Required VS Code extensions**: CSharpier, markdownlint, CSpell. Use the workspace settings + without overrides. + +## Coding standards and conventions + +Code snippets below are illustrative examples only, replace namespaces and types to match your +project. + +### C# language features + +1. **File-scoped namespaces**: + + ```csharp + namespace Example.Project.Library; + ``` + +2. **Nullable reference types**: enabled (`<Nullable>enable</Nullable>`), use nullable annotations + appropriately, use `required` for mandatory properties. +3. **Modern C# features**: prefer modern language constructs, primary constructors when + appropriate, top-level statements for console apps, pattern matching over traditional checks, + collection expressions when types loosely match, extension methods (the classic + `this`-parameter form or an `extension(<receiver>) { ... }` block on C# 14+), implicit object + creation when the type is apparent, range and index operators. +4. **Expression-bodied members**: use for applicable methods, properties, accessors, operators, + lambdas, local functions. +5. **`var` keyword**: do NOT use `var`, always use explicit types: + + ```csharp + // Correct + int count = 42; + string name = "test"; + + // Incorrect + var count = 42; + var name = "test"; + ``` + +### Naming conventions + +1. **Private fields**: underscore prefix with camelCase: + + ```csharp + private readonly HttpClient _httpClient; + private int _counter; + ``` + +2. **Static fields**: `s_` prefix with camelCase: + + ```csharp + private static int s_instanceCount; + ``` + +3. **Constants**: PascalCase: + + ```csharp + private const int MaxRetries = 3; + ``` + +### Code structure + +1. **Global usings**: use `GlobalUsings.cs` for common namespaces: + + ```csharp + global using System; + global using System.Net.Http; + global using System.Threading.Tasks; + global using Microsoft.Extensions.Logging; + ``` + +2. **Usings placement**: outside the namespace, sorted with `System` directives first: + + ```csharp + using System.CommandLine; + using System.Runtime.CompilerServices; + using Example.Project.Library; + + namespace Example.Project.Console; + ``` + +3. **Braces**: Allman style: + + ```csharp + public void Method() + { + if (condition) + { + // code + } + } + ``` + +4. **Indentation**: C# files 4 spaces, XML/csproj files 2 spaces, YAML files 2 spaces, JSON files + 4 spaces. +5. **Line endings**: not specified here, governed per repo by `.editorconfig` / `.gitattributes` + per GOVERNANCE.md's "Line Endings" section. +6. **`#region`**: do not use regions, prefer logical file/folder/namespace organization. +7. **Member ordering (StyleCop SA1201)**: const -> static readonly -> static fields -> instance + readonly fields -> instance fields -> constructors -> public (events -> properties -> indexers + -> methods -> operators) -> non-public in same order -> nested types. + +### Comments and documentation + +XML documentation is on: `<GenerateDocumentationFile>true</GenerateDocumentationFile>`, and +missing XML comments for public APIs are suppressed in `.editorconfig`. Every public surface must +still be documented: a single-line summary, additional details in remarks, documented input +parameters, return values, exceptions, and crefs. + +```csharp +/// <summary> +/// Example of a single line summary. +/// </summary> +/// <remarks> +/// Additional important details about usage. +/// Multiple lines if needed. +/// </remarks> +/// <param name="category"> +/// The quote category to request +/// </param> +/// <param name="cancellationToken"> +/// A <see cref="System.Threading.CancellationToken"/> that can be used to cancel the request. +/// </param> +/// <returns> +/// A <see cref="string"/> containing the quote text. +/// </returns> +/// <exception cref="System.ArgumentException"> +/// Thrown when <paramref name="category"/> is not a supported value. +/// </exception> +public async Task<string> GetQuoteOfTheDayAsync(string category, CancellationToken cancellationToken) {} +``` + +## Analyzer suppressions (.NET) + +CODESTYLE.md's General section sets the suppression-scope order fleet-wide: narrowest scope first, +symbol-scoped before project-scoped before repo-wide, and only for a genuine false-positive or a +deliberate, documented exception, never a blanket relaxation to get a brownfield port to build. +The .NET mechanics, narrowest first: + +- **Never use `#pragma warning disable`** to silence an analyzer. +- **Symbol-scoped**: a `[System.Diagnostics.CodeAnalysis.SuppressMessage(...)]` attribute with a + `Justification`, on the specific member or type: + + ```csharp + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Design", + "CA1034:Nested types should not be visible", + Justification = "https://github.com/dotnet/sdk/issues/51681" + )] + ``` + +- **Project-scoped** (e.g. a test project): a `dotnet_diagnostic.<RULE>.severity` entry in that + project's own `.editorconfig`, with a comment explaining why. +- **Repo-wide**: a `dotnet_diagnostic.<RULE>.severity` entry in the root `.editorconfig`, only + when the rule is genuinely not applicable to any project. Relaxing a batch of `CA*` rules (or + `dotnet_analyzer_diagnostic.severity`) to push a brownfield port through the build is exactly + what this forbids. + +## Error handling and logging + +1. **Structured logging**: use structured message templates. Serilog is the application's concrete + backend, and a library never references it directly (see item 2): + + ```csharp + logger.LogError(exception, "{Function}", function); + ``` + +2. **Libraries log through abstractions, never a concrete backend.** A NuGet library depends only + on `Microsoft.Extensions.Logging.Abstractions` and exposes an `ILoggerFactory` seam: a settable + global factory defaulting to `NullLoggerFactory.Instance` (fallback `NullLogger.Instance`) with + `SetFactory`/`TrySetFactory`, and/or an `ILoggerFactory`/`ILogger` parameter in its API. It + must not reference Serilog or any sink, which would force a logging framework on every consumer + and drag in AOT-incompatible dependencies. The consuming application owns the concrete logger + (Serilog is fine there), bridges it to `ILoggerFactory` (e.g. `SerilogLoggerFactory` from + `Serilog.Extensions.Logging`), and injects it. Reference pattern: a `LogOptions` seam in the + library, against which the consuming CLI builds the Serilog-backed factory and injects it via + `LogOptions.SetFactory`. +3. **CallerMemberName**: use for automatic function name tracking: + + ```csharp + public bool LogAndPropagate( + Exception exception, + [CallerMemberName] string function = "unknown" + ) + ``` + +4. **Logger extensions**: use `Extensions.cs` for logger and other extension methods: + + ```csharp + extension(ILogger logger) + { + public bool LogAndPropagate(Exception exception, ...) { } + } + ``` + +5. **Exceptions**: do not swallow exceptions, either log and rethrow or translate to a + domain-specific exception. + +## Code patterns + +1. **Guard clauses**: prefer early returns for validation and error handling. +2. **Async all the way**: avoid blocking calls (`.Result`, `.Wait()`), use `async`/`await`. +3. **Cancellation tokens**: accept `CancellationToken` as the last parameter and pass it through. +4. **ConfigureAwait**: in library code, use `ConfigureAwait(false)` unless context is required. Do + not call `ConfigureAwait(false)` in xUnit tests (see xUnit1030). +5. **Disposables**: use `await using` for async disposables, prefer `using` declarations. +6. **LINQ vs loops**: use LINQ for clarity, loops for hot paths or allocations. +7. **HTTP**: reuse `HttpClient` via factory, never per-request instantiation. +8. **Collections**: prefer `IReadOnlyList<T>`/`IReadOnlyCollection<T>` for public APIs. +9. **Immutability**: prefer immutable records, use init-only setters when records are not + suitable, and prefer immutable or frozen collections for read-only data. +10. **Exceptions as control flow**: avoid using exceptions for expected flow. +11. **Sealing classes**: seal classes that are not designed for inheritance. +12. **Lazy initialization**: use `Lazy<T>` for static, thread-safe instantiation (e.g. a logger + factory, an HTTP factory). + +## Testing conventions + +1. **Framework**: xUnit v3 or later (the `xunit.v3` package, never the legacy v2 `xunit` package) + with AwesomeAssertions for every assertion. Native xUnit asserts (`Assert.Equal`, + `Assert.True`, ...) are not allowed, use the fluent `.Should()` API. Dynamic test skipping + (`Assert.Skip`, `Assert.SkipWhen`) is control flow, not an assertion, and stays native: + + ```csharp + [Fact] + public void MethodName_Scenario_ExpectedBehavior() + { + // Arrange + int expected = 42; + + // Act + int actual = GetValue(); + + // Assert + actual.Should().Be(expected); + } + ``` + +2. **Organization**: Arrange-Act-Assert pattern. +3. **Naming**: descriptive names with underscores. +4. **Theory tests**: use `[Theory]` with `[InlineData]`. + +## Project configuration + +1. **Target framework**: .NET 10.0 (`<TargetFramework>net10.0</TargetFramework>`). +2. **AOT compatibility**: `<IsAotCompatible>true</IsAotCompatible>`, + `<VerifyReferenceAotCompatibility>true</VerifyReferenceAotCompatibility>`. +3. **Assembly information**: use semantic versioning, include SourceLink + (`<PublishRepositoryUrl>true</PublishRepositoryUrl>`), embed untracked sources + (`<EmbedUntrackedSources>true</EmbedUntrackedSources>`). +4. **Internal visibility**: use `InternalsVisibleTo` for test and benchmark access (adapt the + project names to your repo's test/benchmark projects): + + ```xml + <ItemGroup> + <InternalsVisibleTo Include="YourBenchmarkProject" /> + <InternalsVisibleTo Include="YourTestProject" /> + </ItemGroup> + ``` + +## Best practices + +All changes go through pull requests. diff --git a/.agents/skills/fleet-conformance-check/SKILL.md b/.agents/skills/fleet-conformance-check/SKILL.md index 923c288c..2b6e8406 100644 --- a/.agents/skills/fleet-conformance-check/SKILL.md +++ b/.agents/skills/fleet-conformance-check/SKILL.md @@ -1,74 +1,74 @@ ---- -name: fleet-conformance-check -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 - whenever asked to check if this repo is up to date with the hub, whenever a fleet rule or Skill - seems to not be applying and the cause is unclear, or whenever about to work in a fleet repo and - wanting to confirm the ground under that work is current before trusting it. Needs no standing - hub checkout of its own and no named target repo, only the repo the session is already in, - though the check itself fetches a hub checkout to reach scripts/skills_install.py, since - scripts/ is hub-hosted rather than carried. This is the counterpart to resync-a-repo, which - needs both a hub checkout already in hand and a named external target to drive change from the - hub side instead. Also triggers on "why do I have to keep restating this rule every session," - since a stale or missing Skills install is the most common cause and the cheapest one to rule - out first. ---- - -# Fleet Conformance Check - -## 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, -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. - -## What it checks - -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 - 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. -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 - than by feel. - -## What it is safe to fix on its own - -- **Re-run the installer**, `python3 scripts/skills_install.py`, when the stamp reports stale. - 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. - -## 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, -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. - -## What it escalates instead of touching - -- **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. -- **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. - -## Answering "why isn't a fleet rule applying" - -Check the install stamp first, before assuming a Skill's description is worded wrong or that the -rule was never carried to this repo at all. It is the most common cause, and it is the cheapest -one to confirm. +--- +name: fleet-conformance-check +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 + whenever asked to check if this repo is up to date with the hub, whenever a fleet rule or Skill + seems to not be applying and the cause is unclear, or whenever about to work in a fleet repo and + wanting to confirm the ground under that work is current before trusting it. Needs no standing + hub checkout of its own and no named target repo, only the repo the session is already in, + though the check itself fetches a hub checkout to reach scripts/skills_install.py, since + scripts/ is hub-hosted rather than carried. This is the counterpart to resync-a-repo, which + needs both a hub checkout already in hand and a named external target to drive change from the + hub side instead. Also triggers on "why do I have to keep restating this rule every session," + since a stale or missing Skills install is the most common cause and the cheapest one to rule + out first. +--- + +# Fleet Conformance Check + +## 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, +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. + +## What it checks + +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 + 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. +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 + than by feel. + +## What it is safe to fix on its own + +- **Re-run the installer**, `python3 scripts/skills_install.py`, when the stamp reports stale. + 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. + +## 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, +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. + +## What it escalates instead of touching + +- **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. +- **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. + +## Answering "why isn't a fleet rule applying" + +Check the install stamp first, before assuming a Skill's description is worded wrong or that the +rule was never carried to this repo at all. It is the most common cause, and it is the cheapest +one to confirm. diff --git a/.agents/skills/git-commit-conventions/SKILL.md b/.agents/skills/git-commit-conventions/SKILL.md index 9790f987..3709d31a 100644 --- a/.agents/skills/git-commit-conventions/SKILL.md +++ b/.agents/skills/git-commit-conventions/SKILL.md @@ -1,185 +1,184 @@ ---- -name: git-commit-conventions -description: >- - Governs how an agent stages, commits, signs, and pushes in a ptr727/ProjectTemplate fleet repo: - default-to-staging vs. explicit commit authorization, why "commit" means commit-and-push, the - mandatory signed-commit and noreply-identity checks, never force-pushing, how a history rewrite - must re-identify a commit that is not the agent's own, and the destructive-git-command ban. Use - this whenever about to run git add/commit/push, whenever authorization to commit is ambiguous - ("fix this" versus "commit this"), whenever about to configure or verify commit signing or - git user.email, whenever a merge conflict or a stale branch tempts a force-push or a hard reset, - and whenever rewriting history (filter-repo, an interactive rebase equivalent) touches a commit - authored or committed by someone else. Triggers even when the task looks like routine - housekeeping, such as "clean up this branch" or "just push it", because a scope-widened commit - authorization, an unsigned commit, a fabricated identity, or a force-push are each easy to do by - habit and each one is a hard-to-reverse mistake on a shared branch. ---- - -# Git Commit Conventions - -## Why this exists - -These are the fleet's mechanical git rules for producing a commit, kept in one place instead of -re-derived per repo or per session: whether to commit at all, what committing implies, how -signing and identity are verified rather than configured, and which commands are never run -without being asked. None of these are style preferences. Branch protection enforces several of -them at push time, and the rest guard against damage a rejected push does not undo (a -scope-widened commit, a rewritten shared history, a destructive reset). - -## Staging versus committing - -- **Default to staging, not committing.** Stage with `git add` and leave `git commit` to the - developer unless the developer has explicitly authorized committing for the current ask ("commit - this", "open a PR"). Authorization is scope-bound: it covers the commits that specific task - needs, not a blanket license for the rest of the session. -- **Stage by explicit path, never `git add -A` or `git add .`.** A blanket add stages whatever - else happens to be in the tree, and what it sweeps in is another task's uncommitted work, - landing in a commit whose subject never mentions it, committed by a session that never saw it. - That sweep has happened, which is why task isolation exists (the `repo-worktree` skill), and - isolation makes a shared tree rare rather than impossible. Name the files this task changed, - and let anything else stay unstaged. -- **"Commit" means commit and push.** An authorization to commit carries the push to the feature - branch the work belongs on, because nothing reviews a local commit. The Copilot review loop, the - required status checks, and the maintainer all read the remote, so stopping at `git commit` - leaves the review unstarted and the branch's state private to one machine, which reads as - progress while none of the gates have run. Push to the feature branch, never to a protected - branch, and never with `--force`. Holding a commit locally is the narrower case: it happens when - the developer asks for it, not by default. -- **Check `git status` before committing, and treat any change this session did not make as a - stop.** The maintainer hand-edits files live, often `README.md`/`HISTORY.md`, sometimes with an - editor's LF -> CRLF flip on top, and a sibling agent session sharing the tree leaves its edits - the same way. Whoever the author is, a change this session did not make is never bundled: ask - whether to include it, or leave it unstaged and say so, rather than committing half-finished - work or stranding it in an unrelated commit. An unexpected change in the tree is also the - signal to re-check isolation per the `repo-worktree` skill, since it may mean another task is - live in this checkout. - -## Signing, verified not configured - -- **Every commit must be cryptographically signed (SSH or GPG).** Branch protection enforces this - on every fleet branch, and an unsigned commit is rejected on push. Signing depends on - environment configuration (`commit.gpgsign`, `user.signingkey`, `gpg.format`), but none of those - values prove signing actually works: `gpg.format=ssh` can sign straight from a key file with no - `ssh-agent` running at all (the common case on Git for Windows), just as GPG can sign - agent-backed or straight from a keyring. **Probing agent liveness (`ssh-add -L`, a `gpg-agent` - check) is not a valid test and must not be used.** It tests one specific delivery path, not - whether a commit actually ends up signed, and a host that signs straight from a key file fails - that probe while signing correctly. -- **Verify with a real scratch commit, read back with git's own verdict, not a text grep.** This - single probe is tech-agnostic (SSH agent-backed, SSH key-file, GPG agent-backed, and GPG keyring - all exercise the same code path) and doubles as the identity check below. Run it once before the - first agent-authored commit of a session. Don't assume a prior session left config correct. The - commit below is plain, deliberately no `-S`: forcing it would still succeed on a host where - `commit.gpgsign` is unset or false, which is the exact default-config gap this probe exists to - catch, since every real commit an agent makes is plain too: - - This file is CRLF (the repo's Markdown default), and a `\` line continuation stops working - the moment a stray `\r` lands after it, so the probe is one physical line, not backslash-joined - ones: - - ```sh - d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( trap 'rm -rf "$d"' 0; email=$(git config --global --get user.email) && git init -q "$d" && git -C "$d" commit --allow-empty -q -m check && out=$(git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>') && echo "$out" && ae=$(git -C "$d" log -1 --format='%ae') && ce=$(git -C "$d" log -1 --format='%ce') && case "$out" in sig=G\ *|sig=U\ *) true ;; *) false ;; esac && case "$email" in *@users.noreply.github.com) true ;; *) false ;; esac && [ "$ae" = "$email" ] && [ "$ce" = "$email" ] ) - ``` - - PowerShell equivalent: - - ```powershell - $d = Join-Path $env:TEMP ([guid]::NewGuid()) - try { - $email = git config --global --get user.email - git init -q "$d" ` - && git -C "$d" commit --allow-empty -q -m check - $out = git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' - $out - $ae = git -C "$d" log -1 --format='%ae' - $ce = git -C "$d" log -1 --format='%ce' - if ($out -notmatch '^sig=[GU] ' -or $email -notmatch '@users\.noreply\.github\.com$' ` - -or $ae -ne $email -or $ce -ne $email) { - throw "signing/identity check failed: $out" - } - } finally { - if (Test-Path "$d") { Remove-Item -Recurse -Force "$d" } - } - ``` - - `sig` must read `G` (good signature) or `U` (good signature, unrecognized signer). For GPG, `U` - is a valid signature from a key whose trust level is merely undefined, common right after - generating a new key. For SSH, it's a valid signature from a key not found in the local - `allowed_signers` file, which doesn't affect whether GitHub itself verifies the commit, only - local `git verify-commit` output. `sig` is git's own verdict char. Don't grep localized - "Good" text, since that varies by git version and locale. Anything else, or the commit failing - outright, means **do not commit**: surface the actual error to the developer and stop at - `git add`. Nothing else is contrary evidence: not an unreachable agent, not a config value, not a - signature type you can't otherwise explain in past history (see below). -- **A mix of SSH- and GPG-signed commits in history is structural, not a host to track down.** - `git log --pretty='%G? %GK'` shows two distinct shapes, not two health states: a commit committed - by the PR's own author carries that host's own signature type, while a commit committed by - `GitHub <noreply@github.com>` is a squash-merge: GitHub creates and signs that commit itself, - server-side, with GitHub's own GPG key, regardless of what the PR author signed with locally. - Every commit on `develop`/`main` past its first squash-merge shows `GitHub` as committer and a - GPG signature. That's expected on every fleet repo, on every host, and is not evidence anything - is misconfigured. Check `commit.committer.name` before treating a differing signature type as a - clue worth chasing. -- **Signing must be live before the *first* commit, not retrofitted.** Turning on a - require-signed-commits rule against a branch that already carries unsigned commits forces a - rewrite of that entire history to re-sign it, changing every commit SHA and making whoever does - the rewrite the committer and signer of every commit in it (a rebase preserves `author` but not - the original signatures, and one contributor cannot sign for another). During new-repo setup, - never create commits until signing is verified. - -## Identity, verified not set - -**Commit under the committing account's own GitHub `noreply` identity, never a private, personal, -or invented address.** `author` and `committer` on every agent-authored commit are the GitHub -`noreply` address of the account whose key signs the commit, in `username@users.noreply.github.com` -or `ID+username@users.noreply.github.com` form. **Verify it, do not set it**: the scratch commit -from the signing check above already proves this end-to-end. Read its `author=`/`committer=` -output rather than trusting `git config --get user.email` alone, since a global config value -doesn't prove what actually lands on a commit object, and read both rather than the author alone -since a rebase, amend, or cherry-pick can rewrite the committer while leaving the author -untouched. Match both against that address before committing, rather than -writing a repo-local override. The identity is host configuration set globally once, so a repo-local -`user.email` is redundant where the global is right and a silently-shadowing wrong identity where -it is not. A mismatch is a host fault to surface to the maintainer, not to patch per repo, because -a local override hides a broken host that then commits wrong in every other repo on that machine. -A wrong identity is not cosmetic: a private email trips GitHub's email-privacy push protection, and -an invented author pollutes history. It is also a distinct failure from signing (a wrong author -does not by itself fail the signature check), though the ad-hoc identities that produce one are -typically also unsigned, which the signing rule above then rejects independently. - -## Never force push - -Do not run `git push --force` or `git push --force-with-lease` under any circumstances. Force -pushing rewrites shared history and can cause data loss. This holds regardless of how confident -the rewrite looks, a rejected push is recoverable, a force-pushed one is not. - -## History rewrites re-identify only what changed - -**A history rewrite includes only the commits that must change, and re-identifies any commit it -rewrites that is not the agent's own.** Filtering history (`git filter-repo` or an equivalent, for -example to strip PII) re-signs every commit it touches with the rewriter's own key, while the -tooling preserves each commit's original `author`/`committer` unless told otherwise. GitHub -verifies a signature against the commit's `committer` identity, so a signature from the rewriter's -key over a commit still committed by a bot (`dependabot[bot]`, `github-actions[bot]`) or GitHub's -own web-flow does not match its committer and lands `unknown_key`/unverified, which a -require-signed-commits rule then rejects. - -Two gates keep committer and signature aligned: - -1. **Scope the rewrite to only the commits that must be modified.** By default those are the - rewriter's own, whose committer already matches, so a commit that needs no change stays out of - the rewrite entirely and its identity and signature are never touched. -2. **If a commit that must change is not the rewriter's own, set its `committer` to the rewriter's - own signing identity before re-signing** (and its `author` too, since a rewrite that alters - content should not keep attributing it to the bot). The original bot attribution is deliberately - given up as the cost of having to rewrite it. - -Never leave a signature over a commit committed by another identity. Verify after any rewrite that -every rewritten commit is signed and committed under the correct identity -(`git log --show-signature`). - -## Never run destructive git commands without being asked - -`git reset --hard`, `git checkout .`, `git restore .`, `git clean -f`, and anything else that -discards uncommitted work runs only on explicit developer instruction, never as a convenience step -inside a larger task. +--- +name: git-commit-conventions +description: >- + Governs how an agent stages, commits, signs, and pushes in a ptr727/ProjectTemplate fleet repo: + default-to-staging vs. explicit commit authorization, why "commit" means commit-and-push, the + mandatory signed-commit and noreply-identity checks, never force-pushing, how a history rewrite + must re-identify a commit that is not the agent's own, and the destructive-git-command ban. Use + this whenever about to run git add/commit/push, whenever authorization to commit is ambiguous + ("fix this" versus "commit this"), whenever about to configure or verify commit signing or + git user.email, whenever a merge conflict or a stale branch tempts a force-push or a hard reset, + and whenever rewriting history (filter-repo, an interactive rebase equivalent) touches a commit + authored or committed by someone else. Triggers even when the task looks like routine + housekeeping, such as "clean up this branch" or "just push it", because a scope-widened commit + authorization, an unsigned commit, a fabricated identity, or a force-push are each easy to do by + habit and each one is a hard-to-reverse mistake on a shared branch. +--- + +# Git Commit Conventions + +## Why this exists + +These are the fleet's mechanical git rules for producing a commit, kept in one place instead of +re-derived per repo or per session: whether to commit at all, what committing implies, how +signing and identity are verified rather than configured, and which commands are never run +without being asked. None of these are style preferences. Branch protection enforces several of +them at push time, and the rest guard against damage a rejected push does not undo (a +scope-widened commit, a rewritten shared history, a destructive reset). + +## Staging versus committing + +- **Default to staging, not committing.** Stage with `git add` and leave `git commit` to the + developer unless the developer has explicitly authorized committing for the current ask ("commit + this", "open a PR"). Authorization is scope-bound: it covers the commits that specific task + needs, not a blanket license for the rest of the session. +- **Stage by explicit path, never `git add -A` or `git add .`.** A blanket add stages whatever + else happens to be in the tree, and what it sweeps in is another task's uncommitted work, + landing in a commit whose subject never mentions it, committed by a session that never saw it. + That sweep has happened, which is why task isolation exists (the `repo-worktree` skill), and + isolation makes a shared tree rare rather than impossible. Name the files this task changed, + and let anything else stay unstaged. +- **"Commit" means commit and push.** An authorization to commit carries the push to the feature + branch the work belongs on, because nothing reviews a local commit. The Copilot review loop, the + required status checks, and the maintainer all read the remote, so stopping at `git commit` + leaves the review unstarted and the branch's state private to one machine, which reads as + progress while none of the gates have run. Push to the feature branch, never to a protected + branch, and never with `--force`. Holding a commit locally is the narrower case: it happens when + the developer asks for it, not by default. +- **Check `git status` before committing, and treat any change this session did not make as a + stop.** The maintainer hand-edits files live, often `README.md`/`HISTORY.md`, sometimes with an + editor's LF -> CRLF flip on top, and a sibling agent session sharing the tree leaves its edits + the same way. Whoever the author is, a change this session did not make is never bundled: ask + whether to include it, or leave it unstaged and say so, rather than committing half-finished + work or stranding it in an unrelated commit. An unexpected change in the tree is also the + signal to re-check isolation per the `repo-worktree` skill, since it may mean another task is + live in this checkout. + +## Signing, verified not configured + +- **Every commit must be cryptographically signed (SSH or GPG).** Branch protection enforces this + on every fleet branch, and an unsigned commit is rejected on push. Signing depends on + environment configuration (`commit.gpgsign`, `user.signingkey`, `gpg.format`), but none of those + values prove signing actually works: `gpg.format=ssh` can sign straight from a key file with no + `ssh-agent` running at all (the common case on Git for Windows), just as GPG can sign + agent-backed or straight from a keyring. **Probing agent liveness (`ssh-add -L`, a `gpg-agent` + check) is not a valid test and must not be used.** It tests one specific delivery path, not + whether a commit actually ends up signed, and a host that signs straight from a key file fails + that probe while signing correctly. +- **Verify with a real scratch commit, read back with git's own verdict, not a text grep.** This + single probe is tech-agnostic (SSH agent-backed, SSH key-file, GPG agent-backed, and GPG keyring + all exercise the same code path) and doubles as the identity check below. Run it once before the + first agent-authored commit of a session. Don't assume a prior session left config correct. The + commit below is plain, deliberately no `-S`: forcing it would still succeed on a host where + `commit.gpgsign` is unset or false, which is the exact default-config gap this probe exists to + catch, since every real commit an agent makes is plain too: + + The probe is one physical line, not backslash-joined ones, so it copy-pastes cleanly into a + shell: + + ```sh + d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( trap 'rm -rf "$d"' 0; email=$(git config --global --get user.email) && git init -q "$d" && git -C "$d" commit --allow-empty -q -m check && out=$(git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>') && echo "$out" && ae=$(git -C "$d" log -1 --format='%ae') && ce=$(git -C "$d" log -1 --format='%ce') && case "$out" in sig=G\ *|sig=U\ *) true ;; *) false ;; esac && case "$email" in *@users.noreply.github.com) true ;; *) false ;; esac && [ "$ae" = "$email" ] && [ "$ce" = "$email" ] ) + ``` + + PowerShell equivalent: + + ```powershell + $d = Join-Path $env:TEMP ([guid]::NewGuid()) + try { + $email = git config --global --get user.email + git init -q "$d" ` + && git -C "$d" commit --allow-empty -q -m check + $out = git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' + $out + $ae = git -C "$d" log -1 --format='%ae' + $ce = git -C "$d" log -1 --format='%ce' + if ($out -notmatch '^sig=[GU] ' -or $email -notmatch '@users\.noreply\.github\.com$' ` + -or $ae -ne $email -or $ce -ne $email) { + throw "signing/identity check failed: $out" + } + } finally { + if (Test-Path "$d") { Remove-Item -Recurse -Force "$d" } + } + ``` + + `sig` must read `G` (good signature) or `U` (good signature, unrecognized signer). For GPG, `U` + is a valid signature from a key whose trust level is merely undefined, common right after + generating a new key. For SSH, it's a valid signature from a key not found in the local + `allowed_signers` file, which doesn't affect whether GitHub itself verifies the commit, only + local `git verify-commit` output. `sig` is git's own verdict char. Don't grep localized + "Good" text, since that varies by git version and locale. Anything else, or the commit failing + outright, means **do not commit**: surface the actual error to the developer and stop at + `git add`. Nothing else is contrary evidence: not an unreachable agent, not a config value, not a + signature type you can't otherwise explain in past history (see below). +- **A mix of SSH- and GPG-signed commits in history is structural, not a host to track down.** + `git log --pretty='%G? %GK'` shows two distinct shapes, not two health states: a commit committed + by the PR's own author carries that host's own signature type, while a commit committed by + `GitHub <noreply@github.com>` is a squash-merge: GitHub creates and signs that commit itself, + server-side, with GitHub's own GPG key, regardless of what the PR author signed with locally. + Every commit on `develop`/`main` past its first squash-merge shows `GitHub` as committer and a + GPG signature. That's expected on every fleet repo, on every host, and is not evidence anything + is misconfigured. Check `commit.committer.name` before treating a differing signature type as a + clue worth chasing. +- **Signing must be live before the *first* commit, not retrofitted.** Turning on a + require-signed-commits rule against a branch that already carries unsigned commits forces a + rewrite of that entire history to re-sign it, changing every commit SHA and making whoever does + the rewrite the committer and signer of every commit in it (a rebase preserves `author` but not + the original signatures, and one contributor cannot sign for another). During new-repo setup, + never create commits until signing is verified. + +## Identity, verified not set + +**Commit under the committing account's own GitHub `noreply` identity, never a private, personal, +or invented address.** `author` and `committer` on every agent-authored commit are the GitHub +`noreply` address of the account whose key signs the commit, in `username@users.noreply.github.com` +or `ID+username@users.noreply.github.com` form. **Verify it, do not set it**: the scratch commit +from the signing check above already proves this end-to-end. Read its `author=`/`committer=` +output rather than trusting `git config --get user.email` alone, since a global config value +doesn't prove what actually lands on a commit object, and read both rather than the author alone +since a rebase, amend, or cherry-pick can rewrite the committer while leaving the author +untouched. Match both against that address before committing, rather than +writing a repo-local override. The identity is host configuration set globally once, so a repo-local +`user.email` is redundant where the global is right and a silently-shadowing wrong identity where +it is not. A mismatch is a host fault to surface to the maintainer, not to patch per repo, because +a local override hides a broken host that then commits wrong in every other repo on that machine. +A wrong identity is not cosmetic: a private email trips GitHub's email-privacy push protection, and +an invented author pollutes history. It is also a distinct failure from signing (a wrong author +does not by itself fail the signature check), though the ad-hoc identities that produce one are +typically also unsigned, which the signing rule above then rejects independently. + +## Never force push + +Do not run `git push --force` or `git push --force-with-lease` under any circumstances. Force +pushing rewrites shared history and can cause data loss. This holds regardless of how confident +the rewrite looks, a rejected push is recoverable, a force-pushed one is not. + +## History rewrites re-identify only what changed + +**A history rewrite includes only the commits that must change, and re-identifies any commit it +rewrites that is not the agent's own.** Filtering history (`git filter-repo` or an equivalent, for +example to strip PII) re-signs every commit it touches with the rewriter's own key, while the +tooling preserves each commit's original `author`/`committer` unless told otherwise. GitHub +verifies a signature against the commit's `committer` identity, so a signature from the rewriter's +key over a commit still committed by a bot (`dependabot[bot]`, `github-actions[bot]`) or GitHub's +own web-flow does not match its committer and lands `unknown_key`/unverified, which a +require-signed-commits rule then rejects. + +Two gates keep committer and signature aligned: + +1. **Scope the rewrite to only the commits that must be modified.** By default those are the + rewriter's own, whose committer already matches, so a commit that needs no change stays out of + the rewrite entirely and its identity and signature are never touched. +2. **If a commit that must change is not the rewriter's own, set its `committer` to the rewriter's + own signing identity before re-signing** (and its `author` too, since a rewrite that alters + content should not keep attributing it to the bot). The original bot attribution is deliberately + given up as the cost of having to rewrite it. + +Never leave a signature over a commit committed by another identity. Verify after any rewrite that +every rewritten commit is signed and committed under the correct identity +(`git log --show-signature`). + +## Never run destructive git commands without being asked + +`git reset --hard`, `git checkout .`, `git restore .`, `git clean -f`, and anything else that +discards uncommitted work runs only on explicit developer instruction, never as a convenience step +inside a larger task. diff --git a/.agents/skills/operational-vs-release-workflow/SKILL.md b/.agents/skills/operational-vs-release-workflow/SKILL.md index ad30d26c..0834b424 100644 --- a/.agents/skills/operational-vs-release-workflow/SKILL.md +++ b/.agents/skills/operational-vs-release-workflow/SKILL.md @@ -1,155 +1,155 @@ ---- -name: operational-vs-release-workflow -description: >- - Governs how a ptr727/ProjectTemplate fleet repo branches, promotes, and publishes: the - feature -> develop -> main flow, squash-only vs. merge-commit-only branch protection, the two - develop -> main promotion traps (never delete develop, EOL-only conflicts), the two-phase - publish model (PRs smoke-test only, a human merge never auto-publishes), NBGV semantic - versioning, and the operational-repo delta (direct-to-develop commits, advisory CI, dispatch-only - release) that applies instead whenever the registry's workflowModel field for this repo reads - operational rather than release. Use this whenever choosing a target branch for a change, - promoting develop to main, resolving a develop -> main merge conflict, deciding whether a - release repo's config change needs a PR versus an operational repo's config change can commit - straight to develop, bumping version.json, adding or dropping a release target, or reasoning - about why a merge did or didn't trigger a publish. Triggers even when the request sounds like - ordinary git housekeeping ("just push this config fix", "merge develop into main", "cut a - release"), because the two workflow models genuinely differ (a direct-to-develop commit that is - correct in an operational repo is a rule violation in a release repo, and vice versa) and - applying the wrong one is not obviously wrong to a reader who only knows one of the two. ---- - -# Operational vs. Release Workflow - -## Why this exists - -Two workflow models exist because the underlying repos are two different things. Most fleet repos -ship versioned units of delivery, so they earn a feature -> `develop` -> `main` flow with real -release gates. A handful of repos instead track a live service's running state (Home Assistant, -ESPHome, Vantage, home automation configs) where the "release" is the config already committed, -not something built and shipped later. Applying the release model's ceremony to an operational -repo, or skipping the release model's gates on a repo that actually ships versioned artifacts, is -each wrong in its own repo and correct in the other, which is why this is one skill keyed on which -repo you're in rather than two skills that never talk to each other. - -## Which model this repo uses - -Read the registry `workflowModel` field for this repo (`release`, the default, or `operational`). -The rest of this skill's "Branching" and "Publishing" sections describe the `release` model. The -"Operational repositories" section below is the complete delta for `operational` repos. Anything -not mentioned there is unchanged. When in doubt which one applies, check `registry/repos.json` -rather than guessing from the repo's contents. - -## Branching (release model) - -- **GitHub's repository setting for "default branch" reads `main`, but `develop` is where work starts and where in-flight content lives.** A worktree or clone that defaults to "the default branch" lands on `main` and can silently miss content that has merged to `develop` but not yet been promoted. Before branching off a change, or asserting something absent from this repo, check `develop`, not just whichever branch a tool defaulted to. See GOVERNANCE.md "Verification Discipline" on naming the branch a "does not exist" claim was checked against, and the `repo-worktree` skill, which owns the worktree-creation moment this base-branch choice is made at. -- `develop` is the integration branch. Feature branches -> `develop` is **squash-only**, which - keeps `develop` linear. -- `develop -> main` is **merge-commit only** (no squash, no rebase). Merge commits preserve - `develop`'s commit list as a real second-parent reference on `main`, which lets the release - model attribute releases to the develop commits that produced them. Branch protection enforces - this: the `develop` ruleset allows only `squash`, the `main` ruleset allows only `merge`. -- All commits on both branches must be cryptographically signed (SSH or GPG), see - `git-commit-conventions`. Squash and merge commits created via the GitHub UI are signed by - GitHub's web-flow key. -- **`develop` is forward-only, with no `main -> develop` back-merges.** The `develop` ruleset's - squash-only setting physically blocks merge commits on `develop`. Any historical back-merge - commits in `git log` predate this rule and must not be repeated. -- **Never delete `develop`, and take the EOL-only conflict by taking develop's side.** A - promotion PR's head *is* `develop`, so `--delete-branch` deletes it. An EOL-only conflict on a - workflow YAML file resolves on a throwaway branch off `main`, not on `develop`. Full recovery and - conflict-resolution commands: `references/branch-protection-and-promotion.md`. -- **Issue-closing keywords (`Closes #N`, `Fixes #N`) go in the `develop -> main` promotion PR, not - the feature -> `develop` PR.** GitHub auto-closes an issue only when the closing keyword merges - into the **default branch** (`main`), so a feature -> `develop` PR merge never fires it. - Reference the issue in the `develop` PR body if useful, but the actual closing keyword belongs on - the promotion PR. Closing by hand is the ordinary route wherever the keyword cannot fire (a - promotion that already merged without it, or completed work with no promotion imminent), not a - repair for a botched promotion, cite the squash SHA and re-read that commit before closing. -- **Neither ruleset requires branches to be up to date before merging**, for different reasons on - each branch (a graph-based check that would fail every release on `main`, a check that stalls - bot auto-merge on `develop`). Detail: `references/branch-protection-and-promotion.md`. -- **Configuring branch protection: import the committed ruleset payloads, don't hand-build them.** - Exactly two rulesets, named `develop` and `main`. Full procedure, including the operational - `develop` payload and the brownfield-repo signing caveat: - `references/branch-protection-and-promotion.md`. -- **Dependabot and codegen target both `main` and `develop` in parallel**, each branch absorbing - its own bot PRs independently so neither falls behind, with the merge-bot dispatching the merge - form (`--squash`/`--merge`) that matches each PR's base ruleset. Codegen output must be - deterministic from its inputs alone, never per-run state, or the two branches' legs conflict on - every promotion. Full mechanics: `references/branch-protection-and-promotion.md`. -- **App-token workflows authenticate with Client ID, not the deprecated App ID.** Use - `client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}` at any new App-token call site. - -## Publishing (release model) - -- **The two-phase model is the default: PRs build fast, publishing is batched.** A PR only - smoke-tests (unit tests plus a reduced build of the changed targets), it never pushes anything. - `publish-release.yml` is the sole publisher, and each run builds a **single trigger branch** - (`main` a release, `develop` a prerelease). -- **A human merge never auto-publishes.** Publishing fires on a **`workflow_dispatch`** of - `main`/`develop` (a human-initiated release), a **code-affecting bot push to `main`** (the - codegen App merging a Dependabot/codegen PR, gated on `github.actor` so a human - merge/promotion skips it), or a **weekly `schedule`** (Docker only, to refresh the base image). - A source-only repo publishes on dispatch only. -- **The changes-detection job is a required check that must succeed, not just not fail.** A - paths-filter error must never let a target-changing PR merge with its smoke build silently - skipped. A skipped smoke job (no matching change) passes, `failure`/`cancelled` blocks. -- **Versioning is semantic and maintainer-controlled.** `version.json`'s `major.minor` is the - version floor, edited by the maintainer for functional changes only, in the PR that introduces - the work, never on a fixed cadence or mechanically after a release. NBGV appends the git height - automatically on every commit, so a release always gets a fresh build version with **no - post-release bump** and no develop-ahead requirement. -- **Docs reference the 2-digit `major.minor` line, never a 3-digit build.** `README.md`, - `HISTORY.md`, and release notes name the version as `Version 1.0` (the floor), never the concrete - build height, which is both wrong (the real height differs) and a maintenance trap. - "Correcting" `1.0` to `1.0.0` is a defect. -- **A no-op publish (unchanged NBGV `SemVer2`) re-pushes nothing to any target keyed on the - version string, except Docker, which always re-pushes** to pick up upstream base-image - refreshes. Full guarantee and the `version.json` `pathFilters` boundary: - `references/release-publish-mechanics.md`. -- **Adding, dropping, or wiring a release target** (which leaf task, which artifact-naming - contract, which seam a given output belongs to: a GitHub Release asset, a package-registry push, - an image-registry push, a filesystem deploy, or a source-only repo with no build layer at all), - and tracking an upstream release from a wrapper repo: `references/release-publish-mechanics.md`. - See also `WORKFLOW.md` for the full CI/CD contract this section's rules are load-bearing - excerpts of. - -## Operational repositories (the complete delta) - -Everything above is the `release` model. An `operational` repo (registry `workflowModel: -operational`) tracks a live service's running state rather than shipping versioned units of -delivery, and differs from the `release` model in exactly these ways, everything not listed here -stays the same: - -- **Commit configuration directly to `develop`.** There is no feature branch requirement, the - maintainer commits straight to `develop`, and only *occasionally* opens a `develop -> main` PR to - bless a known-good snapshot. The `develop` ruleset drops the PR and status-check gate, so direct - signed pushes are allowed (force-push, deletion, and unsigned commits are still blocked), and CI - runs on the push as **advisory** feedback that never rejects a commit. -- **A PR into `develop` stays available, and CI runs on it, reported but not required.** Dropping - the requirement permits the direct push, it does not withdraw the pull request, so a change worth - reviewing takes one and both paths into `develop` are legitimate. -- **Take the pull request whenever the change is not one a reader takes in at a glance and - reverts cleanly.** What decides it is the shape of the change, not a line count: restructuring - rather than adjusting a value, touching several files at once, introducing a device, an - integration, or an automation that did not exist before, and anything whose failure shows up on - the live service rather than in a lint run are each the pull request case. So is a change the - author cannot state in one sentence. This stays a judgment call by design, adding a - `pull_request` rule to the operational `develop` ruleset would gate the direct push too and - withdraw the allowance the model exists to give. -- **The `main` promotion gate is unchanged.** The shared `main` ruleset still **enforces** the - required `Check pull request workflow status job` on the `develop -> main` PR. For an operational - repo that check is lint/validation only (editorconfig/EOL plus a domain linter such as a Home - Assistant or ESPHome config validation, never unit tests), so `develop` stays the live surface - and a broken config can never reach `main`. -- **Release only by manual dispatch.** Operational repos carry `releaseTrigger: dispatch-only` and - run no codegen or auto-publish bots, publishing **only** on a manual `workflow_dispatch` (the - same source-only release the publisher already supports: tag, source zip, README, LICENSE, - NBGV-versioned), never automatically. The `develop -> main` promotion just blesses a known-good - snapshot, a release is a separate, deliberate dispatch. -- **Fleet sync still applies.** Dependabot's dual-target sync and the App-signed merge-bot run on - **every** tier, operational included, so both branches stay in sync and a promotion stays a - clean forward merge. -- **Line-ending policy differs too**, following the consuming app's native platform rather than the - fleet CRLF default, per the registry `lineEndings` field. That rule belongs to - `comment-and-doc-style`, not repeated here. +--- +name: operational-vs-release-workflow +description: >- + Governs how a ptr727/ProjectTemplate fleet repo branches, promotes, and publishes: the + feature -> develop -> main flow, squash-only vs. merge-commit-only branch protection, the two + develop -> main promotion traps (never delete develop, EOL-only conflicts), the two-phase + publish model (PRs smoke-test only, a human merge never auto-publishes), NBGV semantic + versioning, and the operational-repo delta (direct-to-develop commits, advisory CI, dispatch-only + release) that applies instead whenever the registry's workflowModel field for this repo reads + operational rather than release. Use this whenever choosing a target branch for a change, + promoting develop to main, resolving a develop -> main merge conflict, deciding whether a + release repo's config change needs a PR versus an operational repo's config change can commit + straight to develop, bumping version.json, adding or dropping a release target, or reasoning + about why a merge did or didn't trigger a publish. Triggers even when the request sounds like + ordinary git housekeeping ("just push this config fix", "merge develop into main", "cut a + release"), because the two workflow models genuinely differ (a direct-to-develop commit that is + correct in an operational repo is a rule violation in a release repo, and vice versa) and + applying the wrong one is not obviously wrong to a reader who only knows one of the two. +--- + +# Operational vs. Release Workflow + +## Why this exists + +Two workflow models exist because the underlying repos are two different things. Most fleet repos +ship versioned units of delivery, so they earn a feature -> `develop` -> `main` flow with real +release gates. A handful of repos instead track a live service's running state (Home Assistant, +ESPHome, Vantage, home automation configs) where the "release" is the config already committed, +not something built and shipped later. Applying the release model's ceremony to an operational +repo, or skipping the release model's gates on a repo that actually ships versioned artifacts, is +each wrong in its own repo and correct in the other, which is why this is one skill keyed on which +repo you're in rather than two skills that never talk to each other. + +## Which model this repo uses + +Read the registry `workflowModel` field for this repo (`release`, the default, or `operational`). +The rest of this skill's "Branching" and "Publishing" sections describe the `release` model. The +"Operational repositories" section below is the complete delta for `operational` repos. Anything +not mentioned there is unchanged. When in doubt which one applies, check `registry/repos.json` +rather than guessing from the repo's contents. + +## Branching (release model) + +- **GitHub's repository setting for "default branch" reads `main`, but `develop` is where work starts and where in-flight content lives.** A worktree or clone that defaults to "the default branch" lands on `main` and can silently miss content that has merged to `develop` but not yet been promoted. Before branching off a change, or asserting something absent from this repo, check `develop`, not just whichever branch a tool defaulted to. See GOVERNANCE.md "Verification Discipline" on naming the branch a "does not exist" claim was checked against, and the `repo-worktree` skill, which owns the worktree-creation moment this base-branch choice is made at. +- `develop` is the integration branch. Feature branches -> `develop` is **squash-only**, which + keeps `develop` linear. +- `develop -> main` is **merge-commit only** (no squash, no rebase). Merge commits preserve + `develop`'s commit list as a real second-parent reference on `main`, which lets the release + model attribute releases to the develop commits that produced them. Branch protection enforces + this: the `develop` ruleset allows only `squash`, the `main` ruleset allows only `merge`. +- All commits on both branches must be cryptographically signed (SSH or GPG), see + `git-commit-conventions`. Squash and merge commits created via the GitHub UI are signed by + GitHub's web-flow key. +- **`develop` is forward-only, with no `main -> develop` back-merges.** The `develop` ruleset's + squash-only setting physically blocks merge commits on `develop`. Any historical back-merge + commits in `git log` predate this rule and must not be repeated. +- **Never delete `develop`, and take the EOL-only conflict by taking develop's side.** A + promotion PR's head *is* `develop`, so `--delete-branch` deletes it. An EOL-only conflict on a + workflow YAML file resolves on a throwaway branch off `main`, not on `develop`. Full recovery and + conflict-resolution commands: `references/branch-protection-and-promotion.md`. +- **Issue-closing keywords (`Closes #N`, `Fixes #N`) go in the `develop -> main` promotion PR, not + the feature -> `develop` PR.** GitHub auto-closes an issue only when the closing keyword merges + into the **default branch** (`main`), so a feature -> `develop` PR merge never fires it. + Reference the issue in the `develop` PR body if useful, but the actual closing keyword belongs on + the promotion PR. Closing by hand is the ordinary route wherever the keyword cannot fire (a + promotion that already merged without it, or completed work with no promotion imminent), not a + repair for a botched promotion, cite the squash SHA and re-read that commit before closing. +- **Neither ruleset requires branches to be up to date before merging**, for different reasons on + each branch (a graph-based check that would fail every release on `main`, a check that stalls + bot auto-merge on `develop`). Detail: `references/branch-protection-and-promotion.md`. +- **Configuring branch protection: import the committed ruleset payloads, don't hand-build them.** + Exactly two rulesets, named `develop` and `main`. Full procedure, including the operational + `develop` payload and the brownfield-repo signing caveat: + `references/branch-protection-and-promotion.md`. +- **Dependabot and codegen target both `main` and `develop` in parallel**, each branch absorbing + its own bot PRs independently so neither falls behind, with the merge-bot dispatching the merge + form (`--squash`/`--merge`) that matches each PR's base ruleset. Codegen output must be + deterministic from its inputs alone, never per-run state, or the two branches' legs conflict on + every promotion. Full mechanics: `references/branch-protection-and-promotion.md`. +- **App-token workflows authenticate with Client ID, not the deprecated App ID.** Use + `client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}` at any new App-token call site. + +## Publishing (release model) + +- **The two-phase model is the default: PRs build fast, publishing is batched.** A PR only + smoke-tests (unit tests plus a reduced build of the changed targets), it never pushes anything. + `publish-release.yml` is the sole publisher, and each run builds a **single trigger branch** + (`main` a release, `develop` a prerelease). +- **A human merge never auto-publishes.** Publishing fires on a **`workflow_dispatch`** of + `main`/`develop` (a human-initiated release), a **code-affecting bot push to `main`** (the + codegen App merging a Dependabot/codegen PR, gated on `github.actor` so a human + merge/promotion skips it), or a **weekly `schedule`** (Docker only, to refresh the base image). + A source-only repo publishes on dispatch only. +- **The changes-detection job is a required check that must succeed, not just not fail.** A + paths-filter error must never let a target-changing PR merge with its smoke build silently + skipped. A skipped smoke job (no matching change) passes, `failure`/`cancelled` blocks. +- **Versioning is semantic and maintainer-controlled.** `version.json`'s `major.minor` is the + version floor, edited by the maintainer for functional changes only, in the PR that introduces + the work, never on a fixed cadence or mechanically after a release. NBGV appends the git height + automatically on every commit, so a release always gets a fresh build version with **no + post-release bump** and no develop-ahead requirement. +- **Docs reference the 2-digit `major.minor` line, never a 3-digit build.** `README.md`, + `HISTORY.md`, and release notes name the version as `Version 1.0` (the floor), never the concrete + build height, which is both wrong (the real height differs) and a maintenance trap. + "Correcting" `1.0` to `1.0.0` is a defect. +- **A no-op publish (unchanged NBGV `SemVer2`) re-pushes nothing to any target keyed on the + version string, except Docker, which always re-pushes** to pick up upstream base-image + refreshes. Full guarantee and the `version.json` `pathFilters` boundary: + `references/release-publish-mechanics.md`. +- **Adding, dropping, or wiring a release target** (which leaf task, which artifact-naming + contract, which seam a given output belongs to: a GitHub Release asset, a package-registry push, + an image-registry push, a filesystem deploy, or a source-only repo with no build layer at all), + and tracking an upstream release from a wrapper repo: `references/release-publish-mechanics.md`. + See also `WORKFLOW.md` for the full CI/CD contract this section's rules are load-bearing + excerpts of. + +## Operational repositories (the complete delta) + +Everything above is the `release` model. An `operational` repo (registry `workflowModel: +operational`) tracks a live service's running state rather than shipping versioned units of +delivery, and differs from the `release` model in exactly these ways, everything not listed here +stays the same: + +- **Commit configuration directly to `develop`.** There is no feature branch requirement, the + maintainer commits straight to `develop`, and only *occasionally* opens a `develop -> main` PR to + bless a known-good snapshot. The `develop` ruleset drops the PR and status-check gate, so direct + signed pushes are allowed (force-push, deletion, and unsigned commits are still blocked), and CI + runs on the push as **advisory** feedback that never rejects a commit. +- **A PR into `develop` stays available, and CI runs on it, reported but not required.** Dropping + the requirement permits the direct push, it does not withdraw the pull request, so a change worth + reviewing takes one and both paths into `develop` are legitimate. +- **Take the pull request whenever the change is not one a reader takes in at a glance and + reverts cleanly.** What decides it is the shape of the change, not a line count: restructuring + rather than adjusting a value, touching several files at once, introducing a device, an + integration, or an automation that did not exist before, and anything whose failure shows up on + the live service rather than in a lint run are each the pull request case. So is a change the + author cannot state in one sentence. This stays a judgment call by design, adding a + `pull_request` rule to the operational `develop` ruleset would gate the direct push too and + withdraw the allowance the model exists to give. +- **The `main` promotion gate is unchanged.** The shared `main` ruleset still **enforces** the + required `Check pull request workflow status job` on the `develop -> main` PR. For an operational + repo that check is lint/validation only (editorconfig/EOL plus a domain linter such as a Home + Assistant or ESPHome config validation, never unit tests), so `develop` stays the live surface + and a broken config can never reach `main`. +- **Release only by manual dispatch.** Operational repos carry `releaseTrigger: dispatch-only` and + run no codegen or auto-publish bots, publishing **only** on a manual `workflow_dispatch` (the + same source-only release the publisher already supports: tag, source zip, README, LICENSE, + NBGV-versioned), never automatically. The `develop -> main` promotion just blesses a known-good + snapshot, a release is a separate, deliberate dispatch. +- **Fleet sync still applies.** Dependabot's dual-target sync and the App-signed merge-bot run on + **every** tier, operational included, so both branches stay in sync and a promotion stays a + clean forward merge. +- **Line-ending policy differs too**, following the consuming app's native platform rather than the + fleet LF default, per the registry `lineEndings` field. That rule belongs to + `comment-and-doc-style`, not repeated here. diff --git a/.agents/skills/operational-vs-release-workflow/references/branch-protection-and-promotion.md b/.agents/skills/operational-vs-release-workflow/references/branch-protection-and-promotion.md index 94a9068a..f30abd0a 100644 --- a/.agents/skills/operational-vs-release-workflow/references/branch-protection-and-promotion.md +++ b/.agents/skills/operational-vs-release-workflow/references/branch-protection-and-promotion.md @@ -1,113 +1,114 @@ -# Branch Protection and Promotion Mechanics - -Full detail for the "Branching" rules in `SKILL.md`. Load this when configuring or reconstructing -branch protection on a fleet repo, executing a `develop -> main` promotion, recovering a lost -`develop`, resolving an EOL-only promotion conflict, or working on the dual-target bot wiring -(Dependabot, codegen, the merge-bot), not for an ordinary feature-branch PR (the SKILL.md summary -covers that case). - -## Configuring branch protection: don't hand-build the rules - -Reconstructing rulesets by hand is error-prone and has gone wrong on past ports. First delete -**all** legacy classic branch-protection rules and any stray rulesets (rulesets are the *only* -mechanism used), then create **exactly two rulesets named `develop` and `main`** by importing the -committed `repo-config/*.json` ruleset payloads via `gh api -X POST "repos/<owner>/<repo>/rulesets"` -(`gh ruleset` is read-only). The names are load-bearing, other governance content and the -workflows reference them. Operational repos import `repo-config/operational/develop.json` as their -`develop` ruleset (the `main` ruleset is shared), and the hub's `repo-config/configure.sh`, run -from a hub checkout against the repository named on its command line, selects the right `develop` -payload from the registry `workflowModel` automatically. **Brownfield repos** (pre-existing -history) need an extra step: `Require signed commits` rejects legacy unsigned commits and the -admin bypass does not cover `git push --force`, so re-signing requires temporarily disabling the -ruleset. See `repo-config/README.md` "Rulesets" for the configured state. - -## 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 `repo-config/settings.json` - 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/<owner>/<repo>/git/refs" -f ref=refs/heads/develop -f sha="$(gh pr view <n> --json headRefOid --jq .headRefOid)"`. -- **Spurious EOL-only conflicts resolve by taking `develop`.** When `develop` declares workflow - YAML as LF while `main` is still CRLF, `develop -> main` conflicts *whole-file* on those paths. - `develop`'s `required_linear_history` plus PR rulesets forbid resolving on `develop` (no merge - commit, no force-push), so resolve on a throwaway branch off `main`: - `git checkout -b promote/develop-to-main origin/main && git merge origin/develop`, take - `develop`'s side for the EOL-conflicted files (`git checkout --theirs <file>`) **after - confirming each is content-identical modulo EOL, or that `develop` is a strict superset** - (`diff <(git show :2:f | tr -d '\r') <(git show :3:f | tr -d '\r')`), then open that branch into - `main`. Verify no genuine `main`-only content is dropped (build/test where the repo supports it). - -## Why both rulesets omit "Require branches to be up to date before merging" - -The flag is off on `main` and on `develop`, for related but distinct reasons. - -- **Main**: the check is graph-based, it asks whether `main`'s tip commit is reachable from - `develop`, not whether the two branches have the same content. After any `develop -> main` - release, `main`'s tip is a brand-new merge commit that `develop`'s history doesn't contain. - Forward-only `develop` never adds it (no back-merge of `main` into `develop`), so the check - would fail on every subsequent release. Other technical workarounds (rebasing `develop` onto - `main`, or rewriting `develop`'s history) exist but contradict the squash-only `develop` ruleset - and the linearity invariant. -- **Develop**: the check stalls bot auto-merge when two bot PRs against `develop` land within the - same window. As soon as the first merges, the second flips to `mergeStateStatus: BEHIND` and - GitHub's auto-merge will not fire while strict is on. The merge-bot only *enables* auto-merge on - `opened`/`reopened` and never auto-updates bot branches, and Dependabot's rebase isn't real-time, - so the second PR sits OPEN with all checks green indefinitely. Squash mechanics still rebase the - diff onto `develop`'s tip on merge, `required_linear_history` still enforces linearity, textual - conflicts still block `mergeable: CONFLICTING`, and the required `Check pull request workflow - status job` still gates merges. The only thing lost is pre-merge detection of - *semantic-but-not-textual* conflicts, which the post-merge `develop` CI run catches anyway. - -## Dual-target bots - -**Dependabot and codegen target both `main` and `develop` in parallel.** -`.github/dependabot.yml` duplicates every ecosystem entry (one per branch) and the codegen -workflow runs as a matrix over both branches with branch names `codegen-main` and -`codegen-develop`. Each branch absorbs its own bot PRs independently, so neither falls behind, and -the forward-only rule still holds, nothing is back-merged from `main` to `develop`, both branches -receive their updates directly. The merge-bot (`.github/workflows/merge-bot-pull-request.yml`) -dispatches `--squash` or `--merge` from each PR's base ref via a `case` statement so the form -matches the ruleset on either base. Dependabot **security** PRs (CVE-driven) always open against -the repo default branch (`main`) regardless of `target-branch`, and the same `case` statement -covers them. The merge-bot auto-merges **every** Dependabot tier including semver-major (no -ecosystem or update-type guard), the required CI checks are the gate, not the bump magnitude, so a -major that breaks the build fails its checks and never merges. - -**Why parallel dual-target rather than develop-only with eventual flow-through:** -push-distribution channels (HACS for Home Assistant integrations, Linux distros that vendor from -`main`, etc.) consume `main` directly. A develop-only model would leave `main` running stale code -during long-running develop features. Codegen content can also be production-critical (live -API-derived data, language lists, build catalogs) rather than just sample/demo content, so both -branches need fresh codegen on their own cadence. - -**Maintainer-pushed commits on a bot PR auto-disable auto-merge.** The merge-bot's -`merge-dependabot` and `merge-codegen` jobs only fire on `opened`/`reopened` events (auto-merge is -enabled exactly once per PR). When a maintainer pushes commits to a bot's branch (a `synchronize` -event with an actor that isn't the same bot), the merge-bot's -`disable-auto-merge-on-maintainer-push` job fires and calls `gh pr merge --disable-auto`. The -maintainer's commits stay in the PR but won't auto-merge with the bot's content. Re-enable -auto-merge manually (`gh pr merge --auto <PR>` or the GitHub UI) when ready. - -## Codegen determinism - -The codegen workflow is a mechanism to refresh files that are checked into the repo: it runs a -matrix over `main` and `develop`, each leg regenerating against its own checkout and opening its -own PR (`codegen-main -> main`, `codegen-develop -> develop`). For the two legs not to conflict on -`develop -> main`, the generated output must depend only on its inputs, never on per-invocation -state (timestamps, GUIDs, build IDs), which would diverge every run and conflict on every release. -**What** a repo regenerates (data files, source, or both) and **how** (download and process an -external source, transform local inputs, whatever) is entirely its own concern. The constraint is -only that the output be input-deterministic, not how it is produced. A repo adopting codegen -supplies its own input-deterministic generator and wires the codegen reference workflow -(`run-codegen-pull-request-task.yml` and its scheduler). - -## App-token workflows use Client ID, not App ID - -`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 `repo-config/README.md` -"Secrets" for which secrets each mechanism needs. +# Branch Protection and Promotion Mechanics + +Full detail for the "Branching" rules in `SKILL.md`. Load this when configuring or reconstructing +branch protection on a fleet repo, executing a `develop -> main` promotion, recovering a lost +`develop`, resolving an EOL-only promotion conflict, or working on the dual-target bot wiring +(Dependabot, codegen, the merge-bot), not for an ordinary feature-branch PR (the SKILL.md summary +covers that case). + +## Configuring branch protection: don't hand-build the rules + +Reconstructing rulesets by hand is error-prone and has gone wrong on past ports. First delete +**all** legacy classic branch-protection rules and any stray rulesets (rulesets are the *only* +mechanism used), then create **exactly two rulesets named `develop` and `main`** by importing the +committed `repo-config/*.json` ruleset payloads via `gh api -X POST "repos/<owner>/<repo>/rulesets"` +(`gh ruleset` is read-only). The names are load-bearing, other governance content and the +workflows reference them. Operational repos import `repo-config/operational/develop.json` as their +`develop` ruleset (the `main` ruleset is shared), and the hub's `repo-config/configure.sh`, run +from a hub checkout against the repository named on its command line, selects the right `develop` +payload from the registry `workflowModel` automatically. **Brownfield repos** (pre-existing +history) need an extra step: `Require signed commits` rejects legacy unsigned commits and the +admin bypass does not cover `git push --force`, so re-signing requires temporarily disabling the +ruleset. See `repo-config/README.md` "Rulesets" for the configured state. + +## 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 `repo-config/settings.json` + 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/<owner>/<repo>/git/refs" -f ref=refs/heads/develop -f sha="$(gh pr view <n> --json headRefOid --jq .headRefOid)"`. +- **Spurious EOL-only conflicts resolve by taking `develop`.** When `develop`'s `.editorconfig` + line-ending default has changed (for example the fleet-wide CRLF-to-LF flip) while `main` hasn't + caught up yet, `develop -> main` conflicts *whole-file* on every renormalized path. + `develop`'s `required_linear_history` plus PR rulesets forbid resolving on `develop` (no merge + commit, no force-push), so resolve on a throwaway branch off `main`: + `git checkout -b promote/develop-to-main origin/main && git merge origin/develop`, take + `develop`'s side for the EOL-conflicted files (`git checkout --theirs <file>`) **after + confirming each is content-identical modulo EOL, or that `develop` is a strict superset** + (`diff <(git show :2:f | tr -d '\r') <(git show :3:f | tr -d '\r')`), then open that branch into + `main`. Verify no genuine `main`-only content is dropped (build/test where the repo supports it). + +## Why both rulesets omit "Require branches to be up to date before merging" + +The flag is off on `main` and on `develop`, for related but distinct reasons. + +- **Main**: the check is graph-based, it asks whether `main`'s tip commit is reachable from + `develop`, not whether the two branches have the same content. After any `develop -> main` + release, `main`'s tip is a brand-new merge commit that `develop`'s history doesn't contain. + Forward-only `develop` never adds it (no back-merge of `main` into `develop`), so the check + would fail on every subsequent release. Other technical workarounds (rebasing `develop` onto + `main`, or rewriting `develop`'s history) exist but contradict the squash-only `develop` ruleset + and the linearity invariant. +- **Develop**: the check stalls bot auto-merge when two bot PRs against `develop` land within the + same window. As soon as the first merges, the second flips to `mergeStateStatus: BEHIND` and + GitHub's auto-merge will not fire while strict is on. The merge-bot only *enables* auto-merge on + `opened`/`reopened` and never auto-updates bot branches, and Dependabot's rebase isn't real-time, + so the second PR sits OPEN with all checks green indefinitely. Squash mechanics still rebase the + diff onto `develop`'s tip on merge, `required_linear_history` still enforces linearity, textual + conflicts still block `mergeable: CONFLICTING`, and the required `Check pull request workflow + status job` still gates merges. The only thing lost is pre-merge detection of + *semantic-but-not-textual* conflicts, which the post-merge `develop` CI run catches anyway. + +## Dual-target bots + +**Dependabot and codegen target both `main` and `develop` in parallel.** +`.github/dependabot.yml` duplicates every ecosystem entry (one per branch) and the codegen +workflow runs as a matrix over both branches with branch names `codegen-main` and +`codegen-develop`. Each branch absorbs its own bot PRs independently, so neither falls behind, and +the forward-only rule still holds, nothing is back-merged from `main` to `develop`, both branches +receive their updates directly. The merge-bot (`.github/workflows/merge-bot-pull-request.yml`) +dispatches `--squash` or `--merge` from each PR's base ref via a `case` statement so the form +matches the ruleset on either base. Dependabot **security** PRs (CVE-driven) always open against +the repo default branch (`main`) regardless of `target-branch`, and the same `case` statement +covers them. The merge-bot auto-merges **every** Dependabot tier including semver-major (no +ecosystem or update-type guard), the required CI checks are the gate, not the bump magnitude, so a +major that breaks the build fails its checks and never merges. + +**Why parallel dual-target rather than develop-only with eventual flow-through:** +push-distribution channels (HACS for Home Assistant integrations, Linux distros that vendor from +`main`, etc.) consume `main` directly. A develop-only model would leave `main` running stale code +during long-running develop features. Codegen content can also be production-critical (live +API-derived data, language lists, build catalogs) rather than just sample/demo content, so both +branches need fresh codegen on their own cadence. + +**Maintainer-pushed commits on a bot PR auto-disable auto-merge.** The merge-bot's +`merge-dependabot` and `merge-codegen` jobs only fire on `opened`/`reopened` events (auto-merge is +enabled exactly once per PR). When a maintainer pushes commits to a bot's branch (a `synchronize` +event with an actor that isn't the same bot), the merge-bot's +`disable-auto-merge-on-maintainer-push` job fires and calls `gh pr merge --disable-auto`. The +maintainer's commits stay in the PR but won't auto-merge with the bot's content. Re-enable +auto-merge manually (`gh pr merge --auto <PR>` or the GitHub UI) when ready. + +## Codegen determinism + +The codegen workflow is a mechanism to refresh files that are checked into the repo: it runs a +matrix over `main` and `develop`, each leg regenerating against its own checkout and opening its +own PR (`codegen-main -> main`, `codegen-develop -> develop`). For the two legs not to conflict on +`develop -> main`, the generated output must depend only on its inputs, never on per-invocation +state (timestamps, GUIDs, build IDs), which would diverge every run and conflict on every release. +**What** a repo regenerates (data files, source, or both) and **how** (download and process an +external source, transform local inputs, whatever) is entirely its own concern. The constraint is +only that the output be input-deterministic, not how it is produced. A repo adopting codegen +supplies its own input-deterministic generator and wires the codegen reference workflow +(`run-codegen-pull-request-task.yml` and its scheduler). + +## App-token workflows use Client ID, not App ID + +`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 `repo-config/README.md` +"Secrets" for which secrets each mechanism needs. diff --git a/.agents/skills/operational-vs-release-workflow/references/release-publish-mechanics.md b/.agents/skills/operational-vs-release-workflow/references/release-publish-mechanics.md index 4847f1d0..d4bf7bcd 100644 --- a/.agents/skills/operational-vs-release-workflow/references/release-publish-mechanics.md +++ b/.agents/skills/operational-vs-release-workflow/references/release-publish-mechanics.md @@ -1,124 +1,124 @@ -# Release Build and Publish Mechanics - -Full detail for the "Publishing" rules in `SKILL.md`. Load this when adding or removing a release -target, wiring a new leaf build task, deciding where a build output belongs (a GitHub Release -asset, a package-registry push, an image push, a deploy), or setting up a wrapper repo that tracks -an upstream release, not for reading the release model's shape (the SKILL.md summary covers that). - -## Reusable-task parameter contract - -Every `build-*-task.yml` and `build-release-task.yml` takes `ref` (git ref to check out/version), -`branch` (logical branch driving config/tags/prerelease, where `main` => Release/`latest`/ -non-prerelease, else Debug/`develop`/prerelease), and where relevant `smoke`. -**Branch-derived config keys off `inputs.branch`**: each run builds one branch, and the top-level -publisher passes `branch: ${{ github.ref_name }}`, which the tasks forward and read as -`inputs.branch` (not `github.ref_name`) for config/tags/prerelease. `get-version-task.yml` takes a -`ref` so NBGV versions the right branch. - -## Per-target subsetting - -`build-release-task.yml` has per-target `enable_*` gates and self-contained leaf tasks, so a -project that drops a target deletes: its `build-<target>-task.yml`, the matching job plus -`github-release`'s `needs` entry in `build-release-task.yml`, its path-filter entry in -`test-pull-request.yml`, and (for PyPI) the `publish-pypi` job in `publish-release.yml`. CodeGen, -versioning, badge, merge-bot, and Dependabot 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` -plus `github-release` jobs inside `build-release-task.yml`, `get-version-task.yml`, -`build-datebadge-task.yml`, and the aggregator shape of `test-pull-request.yml`. Within -`test-pull-request.yml`, only the `changes -> smoke-build -> check-workflow-status` aggregator -wiring and the ruleset-bound job name are verbatim orchestration, while the `unit-test` job and -the `dorny/paths-filter` entries are owned/per-target. The **build** layer (the -`build-<target>-task.yml` leaf tasks) is what a derived project owns and replaces. - -The contract that keeps the seam clean: **a target contributes files to the GitHub release by -uploading a workflow artifact named `release-asset-<branch>-<target>`.** The `github-release` job -collects every `release-asset-<branch>-*` 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-<branch>-<target>` 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): the *list* of leaf jobs in -`build-release-task.yml`. Per the per-target subsetting rule above, delete the target jobs not -shipped and add the ones that are. `build-release-task.yml`'s `github-release` job is untouched, but the file -is not byte-identical because its `needs`/job list reflects the repo's own targets. Making that -list itself target-agnostic is the release-chain phase of `docs/reusable-workflows.md` in the -hub, where the orchestrator becomes a hub-hosted task and each target a composite-action hook, -and until that phase ships the list stays per repo. - -## Map your outputs to the right seam - -Pick by where each artifact *goes*, not by language: - -- **Files attached to the GitHub Release** (zips, binaries, packaged libraries): one leaf task per - output, each uploading `release-asset-<branch>-<name>`. A data-only repo (e.g. a symbol library) - has exactly one such task: validate -> `zip` -> upload `release-asset-<branch>-library`. It - deletes the nuget/pypi/executable/docker jobs and the `publish-pypi` job, keeps `github-release` - as-is. This is also where the .NET `build-executable-task` lives, and it is *not* a generic file - step but specifically `dotnet publish` of the console app, so replace it wholesale, don't adapt - it. -- **Package-registry pushes** (NuGet.org, PyPI): the leaf task both builds **and** publishes to - its registry. NuGet pushes from inside `build-nugetlibrary-task` (`dotnet nuget push - --skip-duplicate`) *and* also uploads a `release-asset-*` (.7z) for the GitHub release. PyPI is - split: `build-pypilibrary-task` only builds and uploads the `pypilibrary-build-<branch>` - artifact, and the separate `publish-pypi` job in `publish-release.yml` does the OIDC - Trusted-Publishing upload (`id-token: write` is granted only at that one entry point), and PyPI - contributes **no** `release-asset-*`. -- **Image-registry pushes** (Docker Hub): `build-docker-task` pushes multi-arch tags directly and - contributes **no** `release-asset-*`. The image tag is build-layer-owned, so drive it from - whatever version source fits (NBGV `SemVer2`, an upstream-release pin, or a per-image matrix). - To publish the Docker Hub repository overview, `publish-docker-readme-task.yml` pushes - `Docker/README.md` via `peter-evans/dockerhub-description` (single-repo by default, matrix per - image for multi-image repos), wired into `publish-release.yml` and gated to `main`. -- **Filesystem on a host the project owns** (a static site, a config tree): a deploy leaf builds - the tree and ships it over the repo's own transport, contributing **no** `release-asset-*`. It - is a **separate `workflow_dispatch`** from the release, so a redeploy of an unchanged commit - mints no tag, and its credentials come from a **per-environment GitHub Environment** rather than - the repository secret store. Its last step asserts what the host actually serves, the release id - and the environment, never that the transport exited zero. Retention at the destination is - bounded by a declared count, and one side is recorded as owning the prune: the deploy where its - credential can observe the destination, the host where that credential is deliberately - write-only. -- **Source-only / no build** (validate + tag + release): this seam does not apply. A source-only - repo carries **no** `build-release-task.yml` (its `appliesTo` excludes it), so there are no leaf - tasks and no `get-version`/`github-release`/`date-badge` jobs to curate. Its whole release is - the standalone `publish-release.yml` on `workflow_dispatch`: a `validate` job (the repo's - reusable validation task) gates a publish job that **inlines** NBGV for the tag and - `action-gh-release` for the release (tag, auto source archive, README, LICENSE). - -`get-version-task.yml` installs the .NET SDK only because NBGV needs the runtime to compute the -version/tag, which is heavyweight but expected even for a non-.NET repo, and acceptable as-is. - -## No-op republish guarantee - -A weekly/dispatch publish where NBGV `SemVer2` is **unchanged** (no new commit since the last -publish) re-pushes **nothing** to GitHub Releases (the `github-release` job's `release-exists` -check skips the create step), NuGet (`dotnet nuget push --skip-duplicate`), or PyPI -(`gh-action-pypi-publish` `skip-existing: true`), since all three key on the version string. -**Docker always re-pushes** by design: it picks up upstream base-image refreshes (e.g. -`ubuntu:rolling`) that aren't visible in the repo. Boundary: `version.json` has **no -`pathFilters`**, so *any* commit, including a CI/workflow-only or docs-only change, advances the -NBGV git height and therefore `SemVer2`, and the next publish *does* create a fresh release for it -even when the shipped binary is byte-identical. This is accepted NBGV behavior, and `pathFilters` -are intentionally not added. - -## Wrapper repos that track an upstream release - -A repo wrapping an upstream release uses `check-upstream-version-task.yml`: a resolver command -prints the upstream version(s) as a **JSON object of `name -> version`**, written to a committed -state file at the **repo root beside `version.json`** (default `upstream-version.json`, since it -is a build-input version source, not GitHub-platform config, so it does not belong under -`.github/`), and opens a rolling App-signed bump PR per branch that the merge-bot auto-merges -(`merge-upstream-version`). The object carries one key for the common single-version case -(`{"version": "X"}`) or N keys for a wrapper that pins several upstream components (e.g. an image -plus a companion tool), and the build reads each component by key, and the bump PR's title/body -name only the keys that actually moved. Call it from a scheduled entry-point workflow and matrix -only the branches that ship the version (a CI-only version uses `["develop"]`). A merged bump -ships on the **next publish**, not immediately, which is the two-phase latency tradeoff. +# Release Build and Publish Mechanics + +Full detail for the "Publishing" rules in `SKILL.md`. Load this when adding or removing a release +target, wiring a new leaf build task, deciding where a build output belongs (a GitHub Release +asset, a package-registry push, an image push, a deploy), or setting up a wrapper repo that tracks +an upstream release, not for reading the release model's shape (the SKILL.md summary covers that). + +## Reusable-task parameter contract + +Every `build-*-task.yml` and `build-release-task.yml` takes `ref` (git ref to check out/version), +`branch` (logical branch driving config/tags/prerelease, where `main` => Release/`latest`/ +non-prerelease, else Debug/`develop`/prerelease), and where relevant `smoke`. +**Branch-derived config keys off `inputs.branch`**: each run builds one branch, and the top-level +publisher passes `branch: ${{ github.ref_name }}`, which the tasks forward and read as +`inputs.branch` (not `github.ref_name`) for config/tags/prerelease. `get-version-task.yml` takes a +`ref` so NBGV versions the right branch. + +## Per-target subsetting + +`build-release-task.yml` has per-target `enable_*` gates and self-contained leaf tasks, so a +project that drops a target deletes: its `build-<target>-task.yml`, the matching job plus +`github-release`'s `needs` entry in `build-release-task.yml`, its path-filter entry in +`test-pull-request.yml`, and (for PyPI) the `publish-pypi` job in `publish-release.yml`. CodeGen, +versioning, badge, merge-bot, and Dependabot 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` +plus `github-release` jobs inside `build-release-task.yml`, `get-version-task.yml`, +`build-datebadge-task.yml`, and the aggregator shape of `test-pull-request.yml`. Within +`test-pull-request.yml`, only the `changes -> smoke-build -> check-workflow-status` aggregator +wiring and the ruleset-bound job name are verbatim orchestration, while the `unit-test` job and +the `dorny/paths-filter` entries are owned/per-target. The **build** layer (the +`build-<target>-task.yml` leaf tasks) is what a derived project owns and replaces. + +The contract that keeps the seam clean: **a target contributes files to the GitHub release by +uploading a workflow artifact named `release-asset-<branch>-<target>`.** The `github-release` job +collects every `release-asset-<branch>-*` 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-<branch>-<target>` 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): the *list* of leaf jobs in +`build-release-task.yml`. Per the per-target subsetting rule above, delete the target jobs not +shipped and add the ones that are. `build-release-task.yml`'s `github-release` job is untouched, but the file +is not byte-identical because its `needs`/job list reflects the repo's own targets. Making that +list itself target-agnostic is the release-chain phase of `docs/reusable-workflows.md` in the +hub, where the orchestrator becomes a hub-hosted task and each target a composite-action hook, +and until that phase ships the list stays per repo. + +## Map your outputs to the right seam + +Pick by where each artifact *goes*, not by language: + +- **Files attached to the GitHub Release** (zips, binaries, packaged libraries): one leaf task per + output, each uploading `release-asset-<branch>-<name>`. A data-only repo (e.g. a symbol library) + has exactly one such task: validate -> `zip` -> upload `release-asset-<branch>-library`. It + deletes the nuget/pypi/executable/docker jobs and the `publish-pypi` job, keeps `github-release` + as-is. This is also where the .NET `build-executable-task` lives, and it is *not* a generic file + step but specifically `dotnet publish` of the console app, so replace it wholesale, don't adapt + it. +- **Package-registry pushes** (NuGet.org, PyPI): the leaf task both builds **and** publishes to + its registry. NuGet pushes from inside `build-nugetlibrary-task` (`dotnet nuget push + --skip-duplicate`) *and* also uploads a `release-asset-*` (.7z) for the GitHub release. PyPI is + split: `build-pypilibrary-task` only builds and uploads the `pypilibrary-build-<branch>` + artifact, and the separate `publish-pypi` job in `publish-release.yml` does the OIDC + Trusted-Publishing upload (`id-token: write` is granted only at that one entry point), and PyPI + contributes **no** `release-asset-*`. +- **Image-registry pushes** (Docker Hub): `build-docker-task` pushes multi-arch tags directly and + contributes **no** `release-asset-*`. The image tag is build-layer-owned, so drive it from + whatever version source fits (NBGV `SemVer2`, an upstream-release pin, or a per-image matrix). + To publish the Docker Hub repository overview, `publish-docker-readme-task.yml` pushes + `Docker/README.md` via `peter-evans/dockerhub-description` (single-repo by default, matrix per + image for multi-image repos), wired into `publish-release.yml` and gated to `main`. +- **Filesystem on a host the project owns** (a static site, a config tree): a deploy leaf builds + the tree and ships it over the repo's own transport, contributing **no** `release-asset-*`. It + is a **separate `workflow_dispatch`** from the release, so a redeploy of an unchanged commit + mints no tag, and its credentials come from a **per-environment GitHub Environment** rather than + the repository secret store. Its last step asserts what the host actually serves, the release id + and the environment, never that the transport exited zero. Retention at the destination is + bounded by a declared count, and one side is recorded as owning the prune: the deploy where its + credential can observe the destination, the host where that credential is deliberately + write-only. +- **Source-only / no build** (validate + tag + release): this seam does not apply. A source-only + repo carries **no** `build-release-task.yml` (its `appliesTo` excludes it), so there are no leaf + tasks and no `get-version`/`github-release`/`date-badge` jobs to curate. Its whole release is + the standalone `publish-release.yml` on `workflow_dispatch`: a `validate` job (the repo's + reusable validation task) gates a publish job that **inlines** NBGV for the tag and + `action-gh-release` for the release (tag, auto source archive, README, LICENSE). + +`get-version-task.yml` installs the .NET SDK only because NBGV needs the runtime to compute the +version/tag, which is heavyweight but expected even for a non-.NET repo, and acceptable as-is. + +## No-op republish guarantee + +A weekly/dispatch publish where NBGV `SemVer2` is **unchanged** (no new commit since the last +publish) re-pushes **nothing** to GitHub Releases (the `github-release` job's `release-exists` +check skips the create step), NuGet (`dotnet nuget push --skip-duplicate`), or PyPI +(`gh-action-pypi-publish` `skip-existing: true`), since all three key on the version string. +**Docker always re-pushes** by design: it picks up upstream base-image refreshes (e.g. +`ubuntu:rolling`) that aren't visible in the repo. Boundary: `version.json` has **no +`pathFilters`**, so *any* commit, including a CI/workflow-only or docs-only change, advances the +NBGV git height and therefore `SemVer2`, and the next publish *does* create a fresh release for it +even when the shipped binary is byte-identical. This is accepted NBGV behavior, and `pathFilters` +are intentionally not added. + +## Wrapper repos that track an upstream release + +A repo wrapping an upstream release uses `check-upstream-version-task.yml`: a resolver command +prints the upstream version(s) as a **JSON object of `name -> version`**, written to a committed +state file at the **repo root beside `version.json`** (default `upstream-version.json`, since it +is a build-input version source, not GitHub-platform config, so it does not belong under +`.github/`), and opens a rolling App-signed bump PR per branch that the merge-bot auto-merges +(`merge-upstream-version`). The object carries one key for the common single-version case +(`{"version": "X"}`) or N keys for a wrapper that pins several upstream components (e.g. an image +plus a companion tool), and the build reads each component by key, and the bump PR's title/body +name only the keys that actually moved. Call it from a scheduled entry-point workflow and matrix +only the branches that ship the version (a CI-only version uses `["develop"]`). A merged bump +ships on the **next publish**, not immediately, which is the two-phase latency tradeoff. diff --git a/.agents/skills/pr-review-conduct/SKILL.md b/.agents/skills/pr-review-conduct/SKILL.md index f5c56bcf..3e323cee 100644 --- a/.agents/skills/pr-review-conduct/SKILL.md +++ b/.agents/skills/pr-review-conduct/SKILL.md @@ -1,146 +1,146 @@ ---- -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 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 "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 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 (GitHub Copilot's request/poll/ - reply API calls) live in .github/copilot-instructions.md's "GitHub Copilot Review Runbook," - this skill is the contract that runbook implements, not a replacement for it. ---- - -# PR Review Conduct - -## Why this exists - -`mergeStateStatus: CLEAN` reflects required status checks and any review thread the ruleset's -conversation-resolution requirement already tracks as resolved. It says nothing about whether the -review that resolved those threads actually covered the **current** head SHA, whether it read the -full diff rather than part of it, or whether a suppressed low-confidence finding, which never -opens a thread for the ruleset to see, was ever answered. A PR that looks done, green checks, no -visible comments, routinely still carries a finding nobody has answered. Treating "green" as -"mergeable" is the single most common way this loop gets skipped. - -## Merge Gate, check this before merging or enabling auto-merge - -**Do not merge, and do not enable auto-merge, unless ALL of these hold:** - -1. Required status checks are green, and where they are not, the reason is **read**, never - inferred. `BLOCKED` covers a failed check, a required check nothing is running, an unresolved - thread, and a missing approval alike, and the response differs by cause. -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. -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. -4. Nothing in the review was a shape the tooling could not read (an unrecognized heading, a moved - section, an unfamiliar coverage wording). An unrecognized shape blocks the gate on its own. - File an issue naming it and quoting the body, rather than guessing what the new wording - probably meant. -5. The maintainer has given **explicit** permission to merge. - -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. - -## Expected review loop - -1. Push changes to the PR branch. -2. Re-request a review for the **current head SHA**. Auto-trigger is unreliable, so request it - explicitly (mechanics in the Copilot runbook). The UI is a fallback only. -3. Wait for review activity on that head. A completed review raising **no findings** is a valid - terminal outcome, so don't re-trigger it or read silence as a missing review. A review whose - body says it declined to review is the one exception, and it is terminal the other way: - nothing follows it, and re-requesting the same head just repeats the decline. -4. Triage findings (see below). -5. Apply fixes or write a rationale for declines. -6. Reply to each thread and resolve what was addressed. -7. Re-run the loop after every fix push until no actionable finding remains. - -Drive to green, a review confirmed on the latest head SHA and every actionable finding closed, -then apply the Merge Gate above. **Never exit the loop early.** A round count is not a stopping -condition, and neither is patience running out. - -## Every finding ends in one of five outcomes - -1. **Real, so fix it.** Reply with the fixing commit SHA. For a finding on platform-specific code - (PowerShell, a macOS- or WSL-only path), "fixed" means executed on that platform, per - `agent-conduct` "Before Claiming Done": a fix reasoned out by analogy to a tested equivalent - elsewhere is not yet fixed, and the reply says so rather than claiming the SHA closes it. -2. **Not real, or real but structurally out of scope, so decline in the thread with evidence.** - Disprove a wrong finding with the command and its output, the code path that makes it - impossible, or the rule that governs it. A finding that is factually correct but not this - repo's to fix (a verbatim-fidelity manifest entry byte-locking the section, ownership that - sits elsewhere) declines the same way: name the boundary and cite what proves it. Either shape - closes the thread on its own evidence. An assertion ("this is fine") does not close a finding, - a decline needs evidence the reviewer itself could check. -3. **Real, fixable here, but deliberately left as is, a value call rather than a scope - boundary, so it is the maintainer's, not the agent's.** Reach for this only once outcome 2 is - ruled out, since a scope boundary declines on its own evidence and never needs this outcome at - all. State the finding and why the fix is unwanted, and get an explicit answer in the same - turn, before moving to other work. A plan to ask later is resolution by silence the moment - attention moves elsewhere. If the maintainer is not reachable right now, leave the thread open - and say so, rather than treating the intention to ask as the asking. -4. **Real and worth doing later, so file the issue first, then reply with its link.** A deferral - noted only in a thread is lost the moment the PR merges. -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. - -**A disposition decided on one PR does not carry to the next.** The same finding shape recurring -on a sibling repo or PR, even within one batch or one session, gets its own outcome: its own -evidence-backed decline (outcome 2) or its own explicit maintainer answer (outcome 3). A prior -instance's outcome is context for the new one, never a standing answer to reuse in its place. - -## Triaging findings - -**A low-confidence (suppressed) finding is not a low-value one.** Judge each against the code, -never against its confidence label. Classify before responding: - -- **Bug**, wrong behavior, missing coverage, a real code or doc divergence. Fix it. -- **Style or convention**. If the cited rule matches the existing tree, fix the code. If the rule - contradicts the tree or industry norm, **fix the rule, not the code**, and take it to the - maintainer (outcome 5) rather than bouncing the same code across rounds. -- **Architectural opinion**, a proposed redesign. Surface it with a recommendation, never apply - it unilaterally. - -## Answering a suppressed finding - -A suppressed finding has no thread and no resolved or unresolved state, so an answer needs to -carry its own context: quote the finding (with its `file:line` anchor and enough of the -reviewer's own words to identify it), give one bold verdict per finding (`Fixed in <SHA>`, -`Disproven`, or `No change needed`), state the `(N)` count the block gave so answers can be -checked against findings, and link the review round. **Read every round, not only the head.** A -suppressed finding does not retire when a later push supersedes it, it just stops showing up in a -head-scoped query while still unanswered. - -## Escalate to the maintainer when - -- A genuine design trade-off surfaces (fail-open vs. fail-closed, refactor scope). -- A finding keeps recurring. Bring the pattern and a recommended fix (rule change or code - change), don't keep silently re-declining it. -- A finding is judged real but should not be fixed. That decision is never the agent's alone. -- An architectural redesign is proposed rather than a bug fix. - -## Mechanics live elsewhere - -This skill is the provider-agnostic contract. For the actual GitHub API calls, requesting a -Copilot review, polling for it, matching the suppressed-findings heading (its wording has moved -more than once), verifying head-SHA and diff coverage, replying and resolving threads without a -hand-typed id, see `.github/copilot-instructions.md` "GitHub Copilot Review Runbook" in the repo -being worked in, and reach for `scripts/pr_review.py` (hub-hosted, invoked from a hub checkout) -before hand-rolling any of it. `status` reports coverage, threads, suppressed findings, and shapes -in one call, `wait` backs off in-process, and `reply` resolves a thread by matching the finding's -own words rather than a line number a fix push can move. +--- +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 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 "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 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 (GitHub Copilot's request/poll/ + reply API calls) live in .github/copilot-instructions.md's "GitHub Copilot Review Runbook," + this skill is the contract that runbook implements, not a replacement for it. +--- + +# PR Review Conduct + +## Why this exists + +`mergeStateStatus: CLEAN` reflects required status checks and any review thread the ruleset's +conversation-resolution requirement already tracks as resolved. It says nothing about whether the +review that resolved those threads actually covered the **current** head SHA, whether it read the +full diff rather than part of it, or whether a suppressed low-confidence finding, which never +opens a thread for the ruleset to see, was ever answered. A PR that looks done, green checks, no +visible comments, routinely still carries a finding nobody has answered. Treating "green" as +"mergeable" is the single most common way this loop gets skipped. + +## Merge Gate, check this before merging or enabling auto-merge + +**Do not merge, and do not enable auto-merge, unless ALL of these hold:** + +1. Required status checks are green, and where they are not, the reason is **read**, never + inferred. `BLOCKED` covers a failed check, a required check nothing is running, an unresolved + thread, and a missing approval alike, and the response differs by cause. +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. +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. +4. Nothing in the review was a shape the tooling could not read (an unrecognized heading, a moved + section, an unfamiliar coverage wording). An unrecognized shape blocks the gate on its own. + File an issue naming it and quoting the body, rather than guessing what the new wording + probably meant. +5. The maintainer has given **explicit** permission to merge. + +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. + +## Expected review loop + +1. Push changes to the PR branch. +2. Re-request a review for the **current head SHA**. Auto-trigger is unreliable, so request it + explicitly (mechanics in the Copilot runbook). The UI is a fallback only. +3. Wait for review activity on that head. A completed review raising **no findings** is a valid + terminal outcome, so don't re-trigger it or read silence as a missing review. A review whose + body says it declined to review is the one exception, and it is terminal the other way: + nothing follows it, and re-requesting the same head just repeats the decline. +4. Triage findings (see below). +5. Apply fixes or write a rationale for declines. +6. Reply to each thread and resolve what was addressed. +7. Re-run the loop after every fix push until no actionable finding remains. + +Drive to green, a review confirmed on the latest head SHA and every actionable finding closed, +then apply the Merge Gate above. **Never exit the loop early.** A round count is not a stopping +condition, and neither is patience running out. + +## Every finding ends in one of five outcomes + +1. **Real, so fix it.** Reply with the fixing commit SHA. For a finding on platform-specific code + (PowerShell, a macOS- or WSL-only path), "fixed" means executed on that platform, per + `agent-conduct` "Before Claiming Done": a fix reasoned out by analogy to a tested equivalent + elsewhere is not yet fixed, and the reply says so rather than claiming the SHA closes it. +2. **Not real, or real but structurally out of scope, so decline in the thread with evidence.** + Disprove a wrong finding with the command and its output, the code path that makes it + impossible, or the rule that governs it. A finding that is factually correct but not this + repo's to fix (a verbatim-fidelity manifest entry byte-locking the section, ownership that + sits elsewhere) declines the same way: name the boundary and cite what proves it. Either shape + closes the thread on its own evidence. An assertion ("this is fine") does not close a finding, + a decline needs evidence the reviewer itself could check. +3. **Real, fixable here, but deliberately left as is, a value call rather than a scope + boundary, so it is the maintainer's, not the agent's.** Reach for this only once outcome 2 is + ruled out, since a scope boundary declines on its own evidence and never needs this outcome at + all. State the finding and why the fix is unwanted, and get an explicit answer in the same + turn, before moving to other work. A plan to ask later is resolution by silence the moment + attention moves elsewhere. If the maintainer is not reachable right now, leave the thread open + and say so, rather than treating the intention to ask as the asking. +4. **Real and worth doing later, so file the issue first, then reply with its link.** A deferral + noted only in a thread is lost the moment the PR merges. +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. + +**A disposition decided on one PR does not carry to the next.** The same finding shape recurring +on a sibling repo or PR, even within one batch or one session, gets its own outcome: its own +evidence-backed decline (outcome 2) or its own explicit maintainer answer (outcome 3). A prior +instance's outcome is context for the new one, never a standing answer to reuse in its place. + +## Triaging findings + +**A low-confidence (suppressed) finding is not a low-value one.** Judge each against the code, +never against its confidence label. Classify before responding: + +- **Bug**, wrong behavior, missing coverage, a real code or doc divergence. Fix it. +- **Style or convention**. If the cited rule matches the existing tree, fix the code. If the rule + contradicts the tree or industry norm, **fix the rule, not the code**, and take it to the + maintainer (outcome 5) rather than bouncing the same code across rounds. +- **Architectural opinion**, a proposed redesign. Surface it with a recommendation, never apply + it unilaterally. + +## Answering a suppressed finding + +A suppressed finding has no thread and no resolved or unresolved state, so an answer needs to +carry its own context: quote the finding (with its `file:line` anchor and enough of the +reviewer's own words to identify it), give one bold verdict per finding (`Fixed in <SHA>`, +`Disproven`, or `No change needed`), state the `(N)` count the block gave so answers can be +checked against findings, and link the review round. **Read every round, not only the head.** A +suppressed finding does not retire when a later push supersedes it, it just stops showing up in a +head-scoped query while still unanswered. + +## Escalate to the maintainer when + +- A genuine design trade-off surfaces (fail-open vs. fail-closed, refactor scope). +- A finding keeps recurring. Bring the pattern and a recommended fix (rule change or code + change), don't keep silently re-declining it. +- A finding is judged real but should not be fixed. That decision is never the agent's alone. +- An architectural redesign is proposed rather than a bug fix. + +## Mechanics live elsewhere + +This skill is the provider-agnostic contract. For the actual GitHub API calls, requesting a +Copilot review, polling for it, matching the suppressed-findings heading (its wording has moved +more than once), verifying head-SHA and diff coverage, replying and resolving threads without a +hand-typed id, see `.github/copilot-instructions.md` "GitHub Copilot Review Runbook" in the repo +being worked in, and reach for `scripts/pr_review.py` (hub-hosted, invoked from a hub checkout) +before hand-rolling any of it. `status` reports coverage, threads, suppressed findings, and shapes +in one call, `wait` backs off in-process, and `reply` resolves a thread by matching the finding's +own words rather than a line number a fix push can move. diff --git a/.agents/skills/python-codestyle/SKILL.md b/.agents/skills/python-codestyle/SKILL.md index 9868071e..a5a262d4 100644 --- a/.agents/skills/python-codestyle/SKILL.md +++ b/.agents/skills/python-codestyle/SKILL.md @@ -1,287 +1,286 @@ ---- -name: python-codestyle -description: >- - Governs Python code style for ptr727/ProjectTemplate fleet repos: 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. Use this whenever writing, reviewing, or editing a .py file, a - pyproject.toml, or a uv.lock, whenever deciding whether a Python subtree is a shippable project - or a lint-only scripts tree, whenever choosing pyright versus mypy for a repo's CI gate, or - whenever writing or reviewing a pytest test. Triggers even when the task looks like a small - local fix ("just add a helper function", "silence this lint warning", "add a dependency"), - because the profile split, the ruff-is-authoritative rule, and the ban on backward-compat - shims or impossible-case error handling are each easy to violate one file at a time. Applies - only to a repo's Python side, a repo with no Python has no use for this Skill. ---- - -# Python Codestyle - -## Why this exists - -This is the Python-specific half of the fleet's code style guide, kept in one place instead of -re-derived per repo or per session. CODESTYLE.md's General section still owns the rules every -language shares (clean-compile verification as a concept, the suppression-scope order, tooling -casing in prose), this Skill is everything specific to a Python project on top of that: the two -profiles, the toolchain, layout, and the language-level conventions. - -## Adapt before propagating - -The rules below describe the default Python profile: a package that publishes to PyPI, -type-checked by pyright in strict mode, dependencies in `[dependency-groups]`. A derived repo -often differs, and when it does, adapt these fields to match the repo's actual toolchain rather -than copying verbatim (a verbatim copy that misdescribes the repo is inaccurate and gets rejected -in review). The axes that commonly vary per repo: - -- **Type checker in CI**: pyright strict, mypy in CI with pyright editor-only (Pylance), or both. - Whichever runs in CI is the one the clean-compile and the CI gate invoke. -- **Dependency declaration**: `[dependency-groups]`, or PEP 621 `[project.optional-dependencies]` - (dev tools installed with `uv sync --extra <group>`). -- **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 - `<Repo>.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, - and the canonical task definitions it is written against are the hub `vscode-tasks-python.json` - snippet, which resolves the same way from every repo. - -## Two profiles - -A repo's Python is one of two shapes, declared as the `build` or `lint-only` profile and validated -against the `pyproject.toml` shape. Most of this Skill (uv project, `uv.lock`, `uv run`, src -layout, pytest coverage) describes the Project shape (the `build` profile). The two differ by -whether the Python has third-party runtime dependencies, which shows up structurally in -`pyproject.toml`, so the fleet's audit reads the shape there: - -- **Project** (the `build` profile): the Python has third-party runtime dependencies, or is the - repo's deliverable. It is a PEP 621 uv project: `[project]` with `dependencies` (dev tools in - `[project.optional-dependencies]` or `[dependency-groups]`), a `[build-system]`, and a committed - `uv.lock` (pinned LF, per GOVERNANCE.md's "Line Endings" section). CI runs `uv sync --frozen` + - `uv run <tool>`, so the lockfile pins tool versions. -- **Scripts** (the `lint-only` profile): stdlib-only utility scripts embedded in a non-Python repo - (e.g. a Python tooling subtree of a `csharp` app). Run the tools with `uvx` (no project install, - no lockfile): the `pyproject.toml` carries only tool config (`[tool.ruff]`, `[tool.mypy]`, and - an optional `[tool.pyright]` editor block), with no `[project]`, no `[build-system]`, and no - `uv.lock` (that metadata would misrepresent it as a shippable package). mypy is the type-check - gate (there is no first-party package for pyright strict to anchor on), and a `[tool.pyright]` - block in standard mode keeps Pylance quiet in the editor, the same mypy-gate/pyright-editor - split the build profile uses. There is no lockfile, and a `uvx <tool>@<ver>` pin in a `run:` - step is not something Dependabot tracks, so CI runs `uvx ruff@latest` / `uvx mypy@latest` rather - than a manual pin that would silently go stale. The fleet rule is to pin only what Dependabot - auto-updates (SHA-pinned actions, package deps) and otherwise run latest, so the VS Code tasks, - README, and CI all run the unpinned latest here. `.py` files follow the repo's line-ending - default (CRLF in a CRLF-default repo, and a shebang-executed script is LF-pinned by path, per - GOVERNANCE.md's "Line Endings" section). There is no pytest suite, and `unittest` is the runner - instead. A script that carries a gate still earns tests, written with the standard library's - `unittest` so they run under bare `python3` with nothing installed, as `test_<script>.py` under - a `tests/` directory beside the scripts it exercises (`<scripts-dir>/tests/`), kept apart so a - test never reads as a tool. Within the scripts directory the name carries the kind: a gate that - checks and exits non-zero on a finding takes a `_lint` or `_gate` suffix, and a utility that - does work takes none. Any repo carrying Python carries the Python tooling in CI, coverage - included, this profile too: `uvx ruff@latest check`, `uvx ruff@latest format --check`, - `uvx mypy@latest`, and the unittest suite - under `uvx coverage@latest run -m unittest discover -s <scripts-dir>/tests` with - `coverage report`, informational with no threshold adopted. A co-present `csharp` type still - carries `codecov.yml` for its own tests. - -## Toolchain - -| Tool | Role | Config | -|---|---|---| -| [uv][uv-link] | env, deps, build, publish (build/publish only where the repo ships a package) | `pyproject.toml` `[dependency-groups]` or `[project.optional-dependencies]`, `uv.lock` | -| [hatchling][latest-link] | build backend (published packages) | `pyproject.toml` `[build-system]` | -| [ruff][ruff-link] | lint + format + import sort | `pyproject.toml` `[tool.ruff]` | -| [pyright][pyright-link] | type checker (the default, a strict baseline) | `pyproject.toml` `[tool.pyright]` | -| [mypy][mypy-link] | additional/alternate type checker (optional, the CI checker in a mypy-in-CI repo, required for Home Assistant) | `pyproject.toml` `[tool.mypy]` (or per home-assistant/core) | -| [pytest][docs-link] | test runner | `pyproject.toml` `[tool.pytest.ini_options]` | - -**Type checking targets strongly typed, deterministic code.** pyright in strict mode is the -default baseline on first-party code (a repo may instead run mypy in CI and keep pyright -editor-only via Pylance, per the next paragraph): `[tool.pyright]` `strict = ["src"]`, or the -integration package for a Home Assistant repo, with tests run in standard mode. pyright is the -anchor because Pylance embeds it, so the editor and the CLI/CI (`uv run pyright`) run the same -engine and never disagree. The standalone `ms-pyright.pyright` extension stays in -`unwantedRecommendations` because Pylance covers it. Relax strictness on third-party code only -when a dependency has no usable types and no alternative (e.g. `pandas`): a targeted, commented -`# pyright: ignore[...]` or a scoped `[tool.pyright]` override, never a blanket relaxation. - -**mypy is allowed, and required where the ecosystem demands it, it is not banned.** Running more -than one checker is normal when each serves a purpose (the .NET side pairs CSharpier and -`dotnet format` the same way), and pyright's inference and mypy's plugin ecosystem (e.g. -`pydantic.mypy`) catch different classes of error. A Home Assistant integration runs -`mypy --strict` because the platinum `strict-typing` quality-scale tier requires it, and a -pydantic-heavy library may opt in for the plugin. When a repo uses mypy it runs in CI and the -editor (the `ms-python.mypy-type-checker` extension) so the two stay consistent, and its mypy -command joins the clean-compile. A repo with no such need stays pyright-only, which is lighter and -inherently consistent. - -## Local development loop - -From inside the Python project directory: - -```sh -uv sync # creates .venv, installs deps + dev group -uv run ruff format # auto-format -uv run ruff check --fix # auto-fix lint -uv run ruff check # verify lint clean -uv run ruff format --check # verify format clean -uv run pyright # verify types -uv run pytest # run tests -uv build # produce wheel + sdist in ./dist (published packages only) -``` - -The Python clean-compile is `uv run ruff format` + `uv run ruff check` + the repo's type checker: -`uv run pyright`, or `uv run mypy src` where mypy is the CI checker, or both where the repo runs -both (see Type checking above). Run it, plus `uv run pytest`, before committing. These are -documented commands, and an optional VS Code tasks mirror (all `type: process`, no `&&` shell -chaining, so it runs the same on any task shell) is in the hub `vscode-tasks-python.json` snippet. -CI runs the same clean-compile commands as the authoritative backstop. Git hooks are opt-in, so -wire `pre-commit` for `ruff` and the type checker yourself if you want local enforcement. - -## Layout - -`src` layout, which keeps the package out of the repo root and prevents accidental imports of -unbuilt code: - -```text -<python-project>/ - pyproject.toml - README.md - uv.lock # committed for reproducible CI - src/ - <package_name>/ - __init__.py - _version.py # published packages; a source-only repo uses a static version instead - <modules>.py - tests/ - __init__.py - test_<module>.py -``` - -## Code style - -### Formatting and linting - -- **`ruff format` is authoritative.** Don't argue with the formatter, and if it reformats your - code, that's the final form. Configure (line length, target version) in `pyproject.toml` - `[tool.ruff]`, not via inline `# fmt:` directives. -- **Run `ruff check --fix` before committing.** Most ruff lint rules have safe autofixes, let the - tool handle them. The configured rule families are listed under `[tool.ruff.lint]` `select`. Add - new rule families project-wide rather than scattering inline `# noqa` markers. -- **`# noqa` is a last resort.** When you must use one, scope it narrowly (`# noqa: E501`, not - bare `# noqa`) and add a short comment on the same line explaining why. False-positive patterns - that recur across the codebase belong in `[tool.ruff.lint]` `ignore` or per-file - `[tool.ruff.lint.per-file-ignores]`, with a comment. Porting an existing codebase is not a - license to add `ignore` / `per-file-ignores` blocks to mute newly surfaced lint. Fix it. - -### Comments - -- **Inline `#` comments**: keep tight and local. One line is preferred, but multi-line is fine - when you need to document a non-obvious implementation constraint, a local trade-off, or - coupling that future edits could easily break. Keep that rationale next to the affected block so - the reviewer/maintainer sees it at edit-time. -- **Don't explain what the code does.** Well-named identifiers handle that. Don't reference the - current task ("added for X", "used by Y"), which belongs in the PR description. - -### Docstrings - -- Follow [PEP 257][pep-0257-link]. Focus docstrings primarily on the behavior contract (what - callers and tests can rely on), public semantics, and edge-case expectations. - Implementation-local rationale belongs in inline `#` comments, not docstrings. -- A short one-liner is fine for trivial functions and tests with self-documenting names. -- For non-trivial behavior (non-obvious test scenarios, contracts a test pins, edge cases callers - must know about, design trade-offs that are load-bearing for future maintainers), write a - one-line summary, blank line, then a details paragraph. Multi-paragraph docstrings are fine when - the contract earns it. -- Design notes belong in the code (docstrings or inline comments). They do NOT belong in - `HISTORY.md`, which is end-user release notes, not a design log. - -### Type hints - -- **All public APIs are typed.** The repo's configured type checker runs on `src/` (pyright strict - via `[tool.pyright]` `strict = ["src"]`, or mypy where that is the CI checker), and tests run in - the checker's looser/standard mode. -- **Use modern syntax**: `list[int]` not `List[int]`, `dict[str, X]` not `Dict[str, X]`, - `X | None` not `Optional[X]`, `from __future__ import annotations` only when needed for forward - references. -- **Don't add `# type: ignore` to silence pyright errors without a comment** explaining the - constraint. If a recurring false positive needs suppression, configure it project-wide in - `[tool.pyright]`. A new port doesn't change this, fix freshly surfaced type errors rather than - muting them. - -### Naming - -- `snake_case` for functions, methods, variables, modules, package directories. -- `PascalCase` for classes, type aliases, type vars, enum members. -- `UPPER_SNAKE_CASE` for module-level constants. -- Single leading underscore for module-private, double leading underscore for name-mangled (rare, - and usually means rethink the design). - -### Imports - -- **Let ruff sort imports.** `[tool.ruff.lint]` `select` includes the `I` rule family - (isort-equivalent). Don't hand-sort. -- Standard library first, then third-party, then first-party (the project itself), each block - separated by a blank line, which ruff enforces automatically. -- Avoid wildcard imports (`from x import *`) outside `__init__.py` re-exports. - -### Patterns to avoid - -- **Don't add backward-compat shims, `# removed` markers, or rename-to-`_` for unused vars**, just - delete. Git history is the audit trail. -- **Don't add error handling for impossible cases.** Trust internal code, and validate only at - boundaries (user input, parsed config, external APIs). -- **Don't use exceptions for expected control flow.** Exceptions are for unexpected states. -- **Don't suppress errors silently** (`except Exception: pass`). Either handle the specific - exception and document why it's safe, or let it propagate. - -## Tests - -- `pytest` with the configuration in `[tool.pytest.ini_options]`. Default invocation: - `uv run pytest`. -- One test file per module under test, named `test_<module>.py`. -- Test functions named `test_<scenario>_<expected_behavior>`, descriptive and not numbered. -- Use fixtures (defined in `conftest.py` for shared ones, or per-test for narrowly-scoped) instead - of setup/teardown methods. -- **Avoid mocking when fakes work.** Hand-rolled fakes that implement the protocol you depend on - are usually clearer and break less than `unittest.mock` magic. -- **Test edge cases that the docstring promises**, not implementation details. If the test breaks - when you refactor without changing behavior, the test is asserting on an implementation detail. - -## Versioning - -**Published packages.** `_version.py` ships with `__version__ = "0.0.0"` as a placeholder. Until -you wire `_version.py` to something that increments (the usual options are `hatch-vcs`, a -version.json bridge, or manual bumps), no new PyPI versions will land, and publishing with -`skip-existing: true` keeps a stuck placeholder version from failing the run. - -**Source-only repos** (no PyPI publish, with a source-release on dispatch or no release at all) do -not need `_version.py`: keep a static `version` in `pyproject.toml` `[project]`, or let the -release pipeline's version source (e.g. NBGV plus `version.json`) own the tag. There is no publish -step to guard, so `skip-existing` does not apply. - -## Linter cleanliness - -Before pushing or opening a PR: - -- VS Code's Problems pane should be quiet for the files you touched. The relevant linters are ruff - (via the `charliermarsh.ruff` extension) and pyright (via the `ms-python.python` extension's - bundled Pylance). -- The CI gate is `uv run ruff check`, `uv run ruff format --check`, the repo's type checker - (`uv run pyright` or `uv run mypy src`), and `uv run pytest`, the same commands as the local - loop above, run from the Python project directory (invoked as separate steps, not `&&`-chained, - so the runner shell is irrelevant). -- Markdown in this directory follows CODESTYLE.md's repo-wide Markdown and Spelling rules, - packaged as the `comment-and-doc-style` Skill. - -<!-- External --> - -[docs-link]: https://docs.pytest.org/ -[latest-link]: https://hatch.pypa.io/latest/ -[mypy-link]: https://mypy-lang.org/ -[pep-0257-link]: https://peps.python.org/pep-0257/ -[pyright-link]: https://microsoft.github.io/pyright/ -[ruff-link]: https://docs.astral.sh/ruff/ -[uv-link]: https://docs.astral.sh/uv/ +--- +name: python-codestyle +description: >- + Governs Python code style for ptr727/ProjectTemplate fleet repos: 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. Use this whenever writing, reviewing, or editing a .py file, a + pyproject.toml, or a uv.lock, whenever deciding whether a Python subtree is a shippable project + or a lint-only scripts tree, whenever choosing pyright versus mypy for a repo's CI gate, or + whenever writing or reviewing a pytest test. Triggers even when the task looks like a small + local fix ("just add a helper function", "silence this lint warning", "add a dependency"), + because the profile split, the ruff-is-authoritative rule, and the ban on backward-compat + shims or impossible-case error handling are each easy to violate one file at a time. Applies + only to a repo's Python side, a repo with no Python has no use for this Skill. +--- + +# Python Codestyle + +## Why this exists + +This is the Python-specific half of the fleet's code style guide, kept in one place instead of +re-derived per repo or per session. CODESTYLE.md's General section still owns the rules every +language shares (clean-compile verification as a concept, the suppression-scope order, tooling +casing in prose), this Skill is everything specific to a Python project on top of that: the two +profiles, the toolchain, layout, and the language-level conventions. + +## Adapt before propagating + +The rules below describe the default Python profile: a package that publishes to PyPI, +type-checked by pyright in strict mode, dependencies in `[dependency-groups]`. A derived repo +often differs, and when it does, adapt these fields to match the repo's actual toolchain rather +than copying verbatim (a verbatim copy that misdescribes the repo is inaccurate and gets rejected +in review). The axes that commonly vary per repo: + +- **Type checker in CI**: pyright strict, mypy in CI with pyright editor-only (Pylance), or both. + Whichever runs in CI is the one the clean-compile and the CI gate invoke. +- **Dependency declaration**: `[dependency-groups]`, or PEP 621 `[project.optional-dependencies]` + (dev tools installed with `uv sync --extra <group>`). +- **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 + `<Repo>.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, + and the canonical task definitions it is written against are the hub `vscode-tasks-python.json` + snippet, which resolves the same way from every repo. + +## Two profiles + +A repo's Python is one of two shapes, declared as the `build` or `lint-only` profile and validated +against the `pyproject.toml` shape. Most of this Skill (uv project, `uv.lock`, `uv run`, src +layout, pytest coverage) describes the Project shape (the `build` profile). The two differ by +whether the Python has third-party runtime dependencies, which shows up structurally in +`pyproject.toml`, so the fleet's audit reads the shape there: + +- **Project** (the `build` profile): the Python has third-party runtime dependencies, or is the + repo's deliverable. It is a PEP 621 uv project: `[project]` with `dependencies` (dev tools in + `[project.optional-dependencies]` or `[dependency-groups]`), a `[build-system]`, and a committed + `uv.lock` (pinned LF, per GOVERNANCE.md's "Line Endings" section). CI runs `uv sync --frozen` + + `uv run <tool>`, so the lockfile pins tool versions. +- **Scripts** (the `lint-only` profile): stdlib-only utility scripts embedded in a non-Python repo + (e.g. a Python tooling subtree of a `csharp` app). Run the tools with `uvx` (no project install, + no lockfile): the `pyproject.toml` carries only tool config (`[tool.ruff]`, `[tool.mypy]`, and + an optional `[tool.pyright]` editor block), with no `[project]`, no `[build-system]`, and no + `uv.lock` (that metadata would misrepresent it as a shippable package). mypy is the type-check + gate (there is no first-party package for pyright strict to anchor on), and a `[tool.pyright]` + block in standard mode keeps Pylance quiet in the editor, the same mypy-gate/pyright-editor + split the build profile uses. There is no lockfile, and a `uvx <tool>@<ver>` pin in a `run:` + step is not something Dependabot tracks, so CI runs `uvx ruff@latest` / `uvx mypy@latest` rather + than a manual pin that would silently go stale. The fleet rule is to pin only what Dependabot + auto-updates (SHA-pinned actions, package deps) and otherwise run latest, so the VS Code tasks, + README, and CI all run the unpinned latest here. `.py` files follow the repo's LF line-ending + default (per GOVERNANCE.md's "Line Endings" section). There is no pytest suite, and `unittest` is the runner + instead. A script that carries a gate still earns tests, written with the standard library's + `unittest` so they run under bare `python3` with nothing installed, as `test_<script>.py` under + a `tests/` directory beside the scripts it exercises (`<scripts-dir>/tests/`), kept apart so a + test never reads as a tool. Within the scripts directory the name carries the kind: a gate that + checks and exits non-zero on a finding takes a `_lint` or `_gate` suffix, and a utility that + does work takes none. Any repo carrying Python carries the Python tooling in CI, coverage + included, this profile too: `uvx ruff@latest check`, `uvx ruff@latest format --check`, + `uvx mypy@latest`, and the unittest suite + under `uvx coverage@latest run -m unittest discover -s <scripts-dir>/tests` with + `coverage report`, informational with no threshold adopted. A co-present `csharp` type still + carries `codecov.yml` for its own tests. + +## Toolchain + +| Tool | Role | Config | +|---|---|---| +| [uv][uv-link] | env, deps, build, publish (build/publish only where the repo ships a package) | `pyproject.toml` `[dependency-groups]` or `[project.optional-dependencies]`, `uv.lock` | +| [hatchling][latest-link] | build backend (published packages) | `pyproject.toml` `[build-system]` | +| [ruff][ruff-link] | lint + format + import sort | `pyproject.toml` `[tool.ruff]` | +| [pyright][pyright-link] | type checker (the default, a strict baseline) | `pyproject.toml` `[tool.pyright]` | +| [mypy][mypy-link] | additional/alternate type checker (optional, the CI checker in a mypy-in-CI repo, required for Home Assistant) | `pyproject.toml` `[tool.mypy]` (or per home-assistant/core) | +| [pytest][docs-link] | test runner | `pyproject.toml` `[tool.pytest.ini_options]` | + +**Type checking targets strongly typed, deterministic code.** pyright in strict mode is the +default baseline on first-party code (a repo may instead run mypy in CI and keep pyright +editor-only via Pylance, per the next paragraph): `[tool.pyright]` `strict = ["src"]`, or the +integration package for a Home Assistant repo, with tests run in standard mode. pyright is the +anchor because Pylance embeds it, so the editor and the CLI/CI (`uv run pyright`) run the same +engine and never disagree. The standalone `ms-pyright.pyright` extension stays in +`unwantedRecommendations` because Pylance covers it. Relax strictness on third-party code only +when a dependency has no usable types and no alternative (e.g. `pandas`): a targeted, commented +`# pyright: ignore[...]` or a scoped `[tool.pyright]` override, never a blanket relaxation. + +**mypy is allowed, and required where the ecosystem demands it, it is not banned.** Running more +than one checker is normal when each serves a purpose (the .NET side pairs CSharpier and +`dotnet format` the same way), and pyright's inference and mypy's plugin ecosystem (e.g. +`pydantic.mypy`) catch different classes of error. A Home Assistant integration runs +`mypy --strict` because the platinum `strict-typing` quality-scale tier requires it, and a +pydantic-heavy library may opt in for the plugin. When a repo uses mypy it runs in CI and the +editor (the `ms-python.mypy-type-checker` extension) so the two stay consistent, and its mypy +command joins the clean-compile. A repo with no such need stays pyright-only, which is lighter and +inherently consistent. + +## Local development loop + +From inside the Python project directory: + +```sh +uv sync # creates .venv, installs deps + dev group +uv run ruff format # auto-format +uv run ruff check --fix # auto-fix lint +uv run ruff check # verify lint clean +uv run ruff format --check # verify format clean +uv run pyright # verify types +uv run pytest # run tests +uv build # produce wheel + sdist in ./dist (published packages only) +``` + +The Python clean-compile is `uv run ruff format` + `uv run ruff check` + the repo's type checker: +`uv run pyright`, or `uv run mypy src` where mypy is the CI checker, or both where the repo runs +both (see Type checking above). Run it, plus `uv run pytest`, before committing. These are +documented commands, and an optional VS Code tasks mirror (all `type: process`, no `&&` shell +chaining, so it runs the same on any task shell) is in the hub `vscode-tasks-python.json` snippet. +CI runs the same clean-compile commands as the authoritative backstop. Git hooks are opt-in, so +wire `pre-commit` for `ruff` and the type checker yourself if you want local enforcement. + +## Layout + +`src` layout, which keeps the package out of the repo root and prevents accidental imports of +unbuilt code: + +```text +<python-project>/ + pyproject.toml + README.md + uv.lock # committed for reproducible CI + src/ + <package_name>/ + __init__.py + _version.py # published packages; a source-only repo uses a static version instead + <modules>.py + tests/ + __init__.py + test_<module>.py +``` + +## Code style + +### Formatting and linting + +- **`ruff format` is authoritative.** Don't argue with the formatter, and if it reformats your + code, that's the final form. Configure (line length, target version) in `pyproject.toml` + `[tool.ruff]`, not via inline `# fmt:` directives. +- **Run `ruff check --fix` before committing.** Most ruff lint rules have safe autofixes, let the + tool handle them. The configured rule families are listed under `[tool.ruff.lint]` `select`. Add + new rule families project-wide rather than scattering inline `# noqa` markers. +- **`# noqa` is a last resort.** When you must use one, scope it narrowly (`# noqa: E501`, not + bare `# noqa`) and add a short comment on the same line explaining why. False-positive patterns + that recur across the codebase belong in `[tool.ruff.lint]` `ignore` or per-file + `[tool.ruff.lint.per-file-ignores]`, with a comment. Porting an existing codebase is not a + license to add `ignore` / `per-file-ignores` blocks to mute newly surfaced lint. Fix it. + +### Comments + +- **Inline `#` comments**: keep tight and local. One line is preferred, but multi-line is fine + when you need to document a non-obvious implementation constraint, a local trade-off, or + coupling that future edits could easily break. Keep that rationale next to the affected block so + the reviewer/maintainer sees it at edit-time. +- **Don't explain what the code does.** Well-named identifiers handle that. Don't reference the + current task ("added for X", "used by Y"), which belongs in the PR description. + +### Docstrings + +- Follow [PEP 257][pep-0257-link]. Focus docstrings primarily on the behavior contract (what + callers and tests can rely on), public semantics, and edge-case expectations. + Implementation-local rationale belongs in inline `#` comments, not docstrings. +- A short one-liner is fine for trivial functions and tests with self-documenting names. +- For non-trivial behavior (non-obvious test scenarios, contracts a test pins, edge cases callers + must know about, design trade-offs that are load-bearing for future maintainers), write a + one-line summary, blank line, then a details paragraph. Multi-paragraph docstrings are fine when + the contract earns it. +- Design notes belong in the code (docstrings or inline comments). They do NOT belong in + `HISTORY.md`, which is end-user release notes, not a design log. + +### Type hints + +- **All public APIs are typed.** The repo's configured type checker runs on `src/` (pyright strict + via `[tool.pyright]` `strict = ["src"]`, or mypy where that is the CI checker), and tests run in + the checker's looser/standard mode. +- **Use modern syntax**: `list[int]` not `List[int]`, `dict[str, X]` not `Dict[str, X]`, + `X | None` not `Optional[X]`, `from __future__ import annotations` only when needed for forward + references. +- **Don't add `# type: ignore` to silence pyright errors without a comment** explaining the + constraint. If a recurring false positive needs suppression, configure it project-wide in + `[tool.pyright]`. A new port doesn't change this, fix freshly surfaced type errors rather than + muting them. + +### Naming + +- `snake_case` for functions, methods, variables, modules, package directories. +- `PascalCase` for classes, type aliases, type vars, enum members. +- `UPPER_SNAKE_CASE` for module-level constants. +- Single leading underscore for module-private, double leading underscore for name-mangled (rare, + and usually means rethink the design). + +### Imports + +- **Let ruff sort imports.** `[tool.ruff.lint]` `select` includes the `I` rule family + (isort-equivalent). Don't hand-sort. +- Standard library first, then third-party, then first-party (the project itself), each block + separated by a blank line, which ruff enforces automatically. +- Avoid wildcard imports (`from x import *`) outside `__init__.py` re-exports. + +### Patterns to avoid + +- **Don't add backward-compat shims, `# removed` markers, or rename-to-`_` for unused vars**, just + delete. Git history is the audit trail. +- **Don't add error handling for impossible cases.** Trust internal code, and validate only at + boundaries (user input, parsed config, external APIs). +- **Don't use exceptions for expected control flow.** Exceptions are for unexpected states. +- **Don't suppress errors silently** (`except Exception: pass`). Either handle the specific + exception and document why it's safe, or let it propagate. + +## Tests + +- `pytest` with the configuration in `[tool.pytest.ini_options]`. Default invocation: + `uv run pytest`. +- One test file per module under test, named `test_<module>.py`. +- Test functions named `test_<scenario>_<expected_behavior>`, descriptive and not numbered. +- Use fixtures (defined in `conftest.py` for shared ones, or per-test for narrowly-scoped) instead + of setup/teardown methods. +- **Avoid mocking when fakes work.** Hand-rolled fakes that implement the protocol you depend on + are usually clearer and break less than `unittest.mock` magic. +- **Test edge cases that the docstring promises**, not implementation details. If the test breaks + when you refactor without changing behavior, the test is asserting on an implementation detail. + +## Versioning + +**Published packages.** `_version.py` ships with `__version__ = "0.0.0"` as a placeholder. Until +you wire `_version.py` to something that increments (the usual options are `hatch-vcs`, a +version.json bridge, or manual bumps), no new PyPI versions will land, and publishing with +`skip-existing: true` keeps a stuck placeholder version from failing the run. + +**Source-only repos** (no PyPI publish, with a source-release on dispatch or no release at all) do +not need `_version.py`: keep a static `version` in `pyproject.toml` `[project]`, or let the +release pipeline's version source (e.g. NBGV plus `version.json`) own the tag. There is no publish +step to guard, so `skip-existing` does not apply. + +## Linter cleanliness + +Before pushing or opening a PR: + +- VS Code's Problems pane should be quiet for the files you touched. The relevant linters are ruff + (via the `charliermarsh.ruff` extension) and pyright (via the `ms-python.python` extension's + bundled Pylance). +- The CI gate is `uv run ruff check`, `uv run ruff format --check`, the repo's type checker + (`uv run pyright` or `uv run mypy src`), and `uv run pytest`, the same commands as the local + loop above, run from the Python project directory (invoked as separate steps, not `&&`-chained, + so the runner shell is irrelevant). +- Markdown in this directory follows CODESTYLE.md's repo-wide Markdown and Spelling rules, + packaged as the `comment-and-doc-style` Skill. + +<!-- External --> + +[docs-link]: https://docs.pytest.org/ +[latest-link]: https://hatch.pypa.io/latest/ +[mypy-link]: https://mypy-lang.org/ +[pep-0257-link]: https://peps.python.org/pep-0257/ +[pyright-link]: https://microsoft.github.io/pyright/ +[ruff-link]: https://docs.astral.sh/ruff/ +[uv-link]: https://docs.astral.sh/uv/ diff --git a/.agents/skills/repo-worktree/SKILL.md b/.agents/skills/repo-worktree/SKILL.md index c3769aab..82bf9d30 100644 --- a/.agents/skills/repo-worktree/SKILL.md +++ b/.agents/skills/repo-worktree/SKILL.md @@ -1,134 +1,134 @@ ---- -name: repo-worktree -description: >- - Mandates and mechanizes task isolation in ptr727/ProjectTemplate fleet repos: every task, - including a continuation of a prior session's task, creates its own git worktree on its own - feature branch before its first file edit, based on the branch work starts on (develop on both - fleet workflow models unless the task is explicitly about main-only content, never whichever - branch a tool defaulted to). Also wraps the mechanics: - creating a worktree with git worktree add, the fleet layout convention, listing what is in - flight, and removing a worktree and its branch after merge. Use this whenever about to create - or edit files in a fleet repo, whenever starting or resuming a task, whenever the task's - branch is already checked out in a shared checkout, and whenever creating, listing, or - removing a worktree. Triggers even when the session was launched in the primary checkout or - the change looks like a one-line fix, because the primary checkout is the maintainer's own - surface and the incident this guards against was two sessions sharing one checkout, each - session's blanket add committing the other's uncommitted files. ---- - -# Repo Worktree - -## Why This Exists - -Two agent sessions once ran concurrently in the same primary checkout, on the same feature -branch, neither knowing the other was in the tree. One session's commits swept in the other -session's uncommitted files, so two commits landed carrying work their subjects never mention, -committed by a task that never saw it. No rule fired at the moment it was violated, which is the -first file edit: the commit-time and review-time skills all run after a sweep has already -happened. This skill is that missing task-start surface. `GOVERNANCE.md` "Repository Boundaries -and Write Safety" keeps the isolation law and wins on any disagreement, and the mechanics below -are this skill's own content. - -## The Mandate - -- **Every task isolates into its own worktree before its first file edit.** All new work begins - by creating a unique worktree (or clone) on its own feature branch. The primary checkout is - the maintainer's own surface, so a session launched there isolates before writing rather than - after noticing contention. -- **A continuation re-isolates.** A session resuming prior work finds its branch already checked - out somewhere and naturally resumes there, and that instinct is the hazard: a branch sitting - checked out in a shared tree is exactly how two sessions end up in one checkout. Create a - fresh worktree for the continuation and check the branch out there. -- **The moment is the first file edit, not the commit.** By commit time another task's - uncommitted work can already be swept into the staging area, so isolating late protects - nothing. Reading anywhere is fine, and the worktree exists before the first write. -- **Someone else's tree stays theirs.** A branch that changes when nothing you did changed it, - or an edit of yours reverted with no conflict, means another task is live in that tree, and - the response is to stop rather than to re-apply the edit, per `GOVERNANCE.md` "Repository - Boundaries and Write Safety". - -## The Base Branch - -Base the worktree on the branch work starts on for the repository's model, not on whichever -branch a tool defaulted to. GitHub's own "default branch" setting reads `main`, but on both -fleet workflow models work starts on `develop`, so a worktree defaulted to "the default branch" -lands on `main` and silently misses everything merged to `develop` but not yet promoted. Branch -from `develop` unless the task is explicitly about `main`-only content, per `GOVERNANCE.md` -"Branching Model". Fetch immediately before creating and base on the remote ref, because a clone -is whatever it last fetched rather than the branch it names. - -## Creating a Worktree - -The fleet layout convention keeps every base clone and every in-flight task visible in one -place: - -```text -~/repos/<Repo> base clone, on its default/working branch -~/repos/worktrees/<Repo>-<task-slug> one worktree per in-flight task, own branch -~/repos/upstream/<owner>-<repo> clone of a repo under another owner, not a fork -``` - -The top level carries no owner segment because everything in it is the fleet owner's own, an -original repo and a fork alike. A fork is named `<upstream-owner>-<upstream-repo>` at fork time, -so a fork of `acme/core` is `acme-core`, and its name identifies the upstream project and stays -unique in the flat namespace without an owner segment of its own. A repository adopted as the -owner's own work rather than kept as a fork is detached from its parent and keeps a plain name, -`widget` rather than `initech-widget`, since it no longer tracks anything upstream. - -A clone of a repository under another owner is neither of those, and flattening one collides -rather than merely reading oddly: `acme/core` joined the way a fork is joined **is** the fork's -name, `acme-core`, while reduced to a bare `core` it names no project and collides with the next -`core` cloned from any other owner. Those clones live one level down under `upstream/`, named by -that same join, so `upstream/acme-core` sits beside the fork it would otherwise land on. The -segment states the relationship rather than the owner, so a reference checkout is told from a -working repo without a `git remote` call, and the names under it never compete with the flat -namespace above. The join is ambiguous in the abstract, since a hyphen in either half means -`acme-labs/core` and `acme/labs-core` produce one name, and it is kept anyway because it is the -fork convention's own join: the ambiguity is inherited from the flat namespace above rather than -introduced here, and it surfaces at clone time as a directory that already exists, where the -second clone takes a hand-picked name. A worktree off one of them keeps the flat worktrees path -under the same name, `~/repos/worktrees/<owner>-<repo>-<task-slug>`. Contributing a change from -such a clone is never a push out of it: fork the upstream first, per the -`upstream-contribution-workflow` skill, and that fork's own clone then belongs in the flat -namespace above, under the name this one already has. - -```sh -git -C ~/repos/<Repo> fetch origin develop -git -C ~/repos/<Repo> worktree add ~/repos/worktrees/<Repo>-<task-slug> -b <task-branch> origin/develop -``` - -A continuation attaches the task's existing branch rather than forking a fresh one: - -```sh -git -C ~/repos/<Repo> fetch origin <task-branch> -git -C ~/repos/<Repo> worktree add ~/repos/worktrees/<Repo>-<task-slug> <task-branch> -``` - -When the base clone holds only the remote-tracking ref, the same command creates the local -branch tracking `origin/<task-branch>` 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. - -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 -has, and note that the base clone may live elsewhere than `~/repos/<Repo>`. - -Claude Code's own `EnterWorktree` tool acts only on an explicit instruction from the user or the -project instructions, which is why the carried rules state this mandate in so many words. Given -a `name`, it creates the worktree under `.claude/worktrees/` inside the repo and bases it on the -GitHub default branch, which is the wrong path and the wrong base here. Create the worktree with -`git worktree add` as above, then attach with `EnterWorktree` `path:`, not `name:`. - -## Listing and Cleanup - -- `git worktree list`, run in any checkout of a repo, names that repo's base clone and every - worktree with its branch. On the convention layout, one `ls ~/repos/worktrees/` reads what is - in flight across the whole fleet. -- After the task's pull request merges, remove the worktree and its branch from the base clone: - `git worktree remove ~/repos/worktrees/<Repo>-<task-slug>`, then `git branch -d <task-branch>`. -- A worktree that refuses removal is dirty, and force is not the fix: look at what is - uncommitted in it first, since discarding uncommitted work runs only on explicit instruction, - per the `git-commit-conventions` skill. +--- +name: repo-worktree +description: >- + Mandates and mechanizes task isolation in ptr727/ProjectTemplate fleet repos: every task, + including a continuation of a prior session's task, creates its own git worktree on its own + feature branch before its first file edit, based on the branch work starts on (develop on both + fleet workflow models unless the task is explicitly about main-only content, never whichever + branch a tool defaulted to). Also wraps the mechanics: + creating a worktree with git worktree add, the fleet layout convention, listing what is in + flight, and removing a worktree and its branch after merge. Use this whenever about to create + or edit files in a fleet repo, whenever starting or resuming a task, whenever the task's + branch is already checked out in a shared checkout, and whenever creating, listing, or + removing a worktree. Triggers even when the session was launched in the primary checkout or + the change looks like a one-line fix, because the primary checkout is the maintainer's own + surface and the incident this guards against was two sessions sharing one checkout, each + session's blanket add committing the other's uncommitted files. +--- + +# Repo Worktree + +## Why This Exists + +Two agent sessions once ran concurrently in the same primary checkout, on the same feature +branch, neither knowing the other was in the tree. One session's commits swept in the other +session's uncommitted files, so two commits landed carrying work their subjects never mention, +committed by a task that never saw it. No rule fired at the moment it was violated, which is the +first file edit: the commit-time and review-time skills all run after a sweep has already +happened. This skill is that missing task-start surface. `GOVERNANCE.md` "Repository Boundaries +and Write Safety" keeps the isolation law and wins on any disagreement, and the mechanics below +are this skill's own content. + +## The Mandate + +- **Every task isolates into its own worktree before its first file edit.** All new work begins + by creating a unique worktree (or clone) on its own feature branch. The primary checkout is + the maintainer's own surface, so a session launched there isolates before writing rather than + after noticing contention. +- **A continuation re-isolates.** A session resuming prior work finds its branch already checked + out somewhere and naturally resumes there, and that instinct is the hazard: a branch sitting + checked out in a shared tree is exactly how two sessions end up in one checkout. Create a + fresh worktree for the continuation and check the branch out there. +- **The moment is the first file edit, not the commit.** By commit time another task's + uncommitted work can already be swept into the staging area, so isolating late protects + nothing. Reading anywhere is fine, and the worktree exists before the first write. +- **Someone else's tree stays theirs.** A branch that changes when nothing you did changed it, + or an edit of yours reverted with no conflict, means another task is live in that tree, and + the response is to stop rather than to re-apply the edit, per `GOVERNANCE.md` "Repository + Boundaries and Write Safety". + +## The Base Branch + +Base the worktree on the branch work starts on for the repository's model, not on whichever +branch a tool defaulted to. GitHub's own "default branch" setting reads `main`, but on both +fleet workflow models work starts on `develop`, so a worktree defaulted to "the default branch" +lands on `main` and silently misses everything merged to `develop` but not yet promoted. Branch +from `develop` unless the task is explicitly about `main`-only content, per `GOVERNANCE.md` +"Branching Model". Fetch immediately before creating and base on the remote ref, because a clone +is whatever it last fetched rather than the branch it names. + +## Creating a Worktree + +The fleet layout convention keeps every base clone and every in-flight task visible in one +place: + +```text +~/repos/<Repo> base clone, on its default/working branch +~/repos/worktrees/<Repo>-<task-slug> one worktree per in-flight task, own branch +~/repos/upstream/<owner>-<repo> clone of a repo under another owner, not a fork +``` + +The top level carries no owner segment because everything in it is the fleet owner's own, an +original repo and a fork alike. A fork is named `<upstream-owner>-<upstream-repo>` at fork time, +so a fork of `acme/core` is `acme-core`, and its name identifies the upstream project and stays +unique in the flat namespace without an owner segment of its own. A repository adopted as the +owner's own work rather than kept as a fork is detached from its parent and keeps a plain name, +`widget` rather than `initech-widget`, since it no longer tracks anything upstream. + +A clone of a repository under another owner is neither of those, and flattening one collides +rather than merely reading oddly: `acme/core` joined the way a fork is joined **is** the fork's +name, `acme-core`, while reduced to a bare `core` it names no project and collides with the next +`core` cloned from any other owner. Those clones live one level down under `upstream/`, named by +that same join, so `upstream/acme-core` sits beside the fork it would otherwise land on. The +segment states the relationship rather than the owner, so a reference checkout is told from a +working repo without a `git remote` call, and the names under it never compete with the flat +namespace above. The join is ambiguous in the abstract, since a hyphen in either half means +`acme-labs/core` and `acme/labs-core` produce one name, and it is kept anyway because it is the +fork convention's own join: the ambiguity is inherited from the flat namespace above rather than +introduced here, and it surfaces at clone time as a directory that already exists, where the +second clone takes a hand-picked name. A worktree off one of them keeps the flat worktrees path +under the same name, `~/repos/worktrees/<owner>-<repo>-<task-slug>`. Contributing a change from +such a clone is never a push out of it: fork the upstream first, per the +`upstream-contribution-workflow` skill, and that fork's own clone then belongs in the flat +namespace above, under the name this one already has. + +```sh +git -C ~/repos/<Repo> fetch origin develop +git -C ~/repos/<Repo> worktree add ~/repos/worktrees/<Repo>-<task-slug> -b <task-branch> origin/develop +``` + +A continuation attaches the task's existing branch rather than forking a fresh one: + +```sh +git -C ~/repos/<Repo> fetch origin <task-branch> +git -C ~/repos/<Repo> worktree add ~/repos/worktrees/<Repo>-<task-slug> <task-branch> +``` + +When the base clone holds only the remote-tracking ref, the same command creates the local +branch tracking `origin/<task-branch>` 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. + +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 +has, and note that the base clone may live elsewhere than `~/repos/<Repo>`. + +Claude Code's own `EnterWorktree` tool acts only on an explicit instruction from the user or the +project instructions, which is why the carried rules state this mandate in so many words. Given +a `name`, it creates the worktree under `.claude/worktrees/` inside the repo and bases it on the +GitHub default branch, which is the wrong path and the wrong base here. Create the worktree with +`git worktree add` as above, then attach with `EnterWorktree` `path:`, not `name:`. + +## Listing and Cleanup + +- `git worktree list`, run in any checkout of a repo, names that repo's base clone and every + worktree with its branch. On the convention layout, one `ls ~/repos/worktrees/` reads what is + in flight across the whole fleet. +- After the task's pull request merges, remove the worktree and its branch from the base clone: + `git worktree remove ~/repos/worktrees/<Repo>-<task-slug>`, then `git branch -d <task-branch>`. +- A worktree that refuses removal is dirty, and force is not the fix: look at what is + uncommitted in it first, since discarding uncommitted work runs only on explicit instruction, + per the `git-commit-conventions` skill. diff --git a/.agents/skills/resync-a-repo/SKILL.md b/.agents/skills/resync-a-repo/SKILL.md index 011d68d4..308dcbc9 100644 --- a/.agents/skills/resync-a-repo/SKILL.md +++ b/.agents/skills/resync-a-repo/SKILL.md @@ -1,80 +1,80 @@ ---- -name: resync-a-repo -description: >- - Drives RESYNC.md's procedure for bringing a ptr727/ProjectTemplate fleet repo that is already - stood up back into line with the current hub, run from a hub checkout against a named target - repo. Use this whenever asked to resync, sync, converge, or bring a specific repo up to date - 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. ---- - -# Resync a Repo - -## Why this exists - -RESYNC.md's own apply order already sequences the remedies so the rules land before the files -they govern and a deletion lands before the re-vendor that would otherwise refresh it. The -AGENTS.md-overwrite incident happened inside that same procedure, on the step that looked most -routine. This skill exists so the mandatory check survives contact with a real, time-pressured -resync instead of depending on an agent remembering to run it unprompted. It is a driver over -RESYNC.md, not a replacement for it. Read RESYNC.md itself for the deletion sweep, the -letters-versus-drift routing, the settings and ruleset step, and everything else that does not -change from one resync to the next. - -## Confirm the procedure before starting - -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 <RepoName>`, 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 - -Fetch a hub checkout of your own immediately before reading it, per RESYNC.md section 1, since a -stale clone answers confidently instead of failing, and verify the host with -`python3 scripts/host_gate.py --repo <path-to-target-checkout>`. 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. - -## Apply, in this order - -1. **The instruction set first.** `AGENTS.md` and `GOVERNANCE.md` verbatim sections, then - `CODESTYLE.md` and `WORKFLOW.md`, including the `AGENTS.md` skill-dependency pointer paragraph - (naming `scripts/skills_install.py` and where the fleet's Skills live) as one more verbatim - unit carried in this same step, not a separate pass. **Before any verbatim re-vendor in this - step, run the `carried-instruction-file-guard` skill's distinctive-phrase probe against the - target file, every time, without exception.** This is not advisory language to weigh against - how routine the diff looks, a diff that looks routine is exactly the shape the - AGENTS.md-overwrite incident took. Do not proceed to the re-vendor until the probe has run and - any local addition it finds has a destination, per that skill's own procedure. -2. **Deletions second, before any re-vendor.** Only a `retire` disposition in - `spec/divergences.json` authorizes removing a file, and the removal is swept tree-wide, per - RESYNC.md section 4, before the deletion counts as done. -3. **Verbatim re-vendors** for everything the probe in step 1 cleared. A finding classified - modified rather than stale gets its diff read before being overwritten, since it may be an - improvement the hub should adopt instead of a mistake to erase. -4. **Interface workflows.** Honor the named contract, required jobs, the ruleset-bound check name, - the artifact-name handoff, rather than copying bytes. -5. **Settings, rulesets, and secrets.** Run - `repo-config/configure.sh check <owner>/<repo> release|operational` against the repo by name, - then `apply` for what it reports, never from a carried copy. -6. **Intent files last, and by hand,** since nothing mechanical judges these. - -Reconcile the registry entry (`status`, `types`, `releaseTrigger`, `workflowModel`, -`driftNotes`) in the same pass, and delete a `driftNote` describing work this pass just finished -rather than leaving it standing. - -## Ship it - -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, done means measured, not applied. +--- +name: resync-a-repo +description: >- + Drives RESYNC.md's procedure for bringing a ptr727/ProjectTemplate fleet repo that is already + stood up back into line with the current hub, run from a hub checkout against a named target + repo. Use this whenever asked to resync, sync, converge, or bring a specific repo up to date + 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. +--- + +# Resync a Repo + +## Why this exists + +RESYNC.md's own apply order already sequences the remedies so the rules land before the files +they govern and a deletion lands before the re-vendor that would otherwise refresh it. The +AGENTS.md-overwrite incident happened inside that same procedure, on the step that looked most +routine. This skill exists so the mandatory check survives contact with a real, time-pressured +resync instead of depending on an agent remembering to run it unprompted. It is a driver over +RESYNC.md, not a replacement for it. Read RESYNC.md itself for the deletion sweep, the +letters-versus-drift routing, the settings and ruleset step, and everything else that does not +change from one resync to the next. + +## Confirm the procedure before starting + +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 <RepoName>`, 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 + +Fetch a hub checkout of your own immediately before reading it, per RESYNC.md section 1, since a +stale clone answers confidently instead of failing, and verify the host with +`python3 scripts/host_gate.py --repo <path-to-target-checkout>`. 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. + +## Apply, in this order + +1. **The instruction set first.** `AGENTS.md` and `GOVERNANCE.md` verbatim sections, then + `CODESTYLE.md` and `WORKFLOW.md`, including the `AGENTS.md` skill-dependency pointer paragraph + (naming `scripts/skills_install.py` and where the fleet's Skills live) as one more verbatim + unit carried in this same step, not a separate pass. **Before any verbatim re-vendor in this + step, run the `carried-instruction-file-guard` skill's distinctive-phrase probe against the + target file, every time, without exception.** This is not advisory language to weigh against + how routine the diff looks, a diff that looks routine is exactly the shape the + AGENTS.md-overwrite incident took. Do not proceed to the re-vendor until the probe has run and + any local addition it finds has a destination, per that skill's own procedure. +2. **Deletions second, before any re-vendor.** Only a `retire` disposition in + `spec/divergences.json` authorizes removing a file, and the removal is swept tree-wide, per + RESYNC.md section 4, before the deletion counts as done. +3. **Verbatim re-vendors** for everything the probe in step 1 cleared. A finding classified + modified rather than stale gets its diff read before being overwritten, since it may be an + improvement the hub should adopt instead of a mistake to erase. +4. **Interface workflows.** Honor the named contract, required jobs, the ruleset-bound check name, + the artifact-name handoff, rather than copying bytes. +5. **Settings, rulesets, and secrets.** Run + `repo-config/configure.sh check <owner>/<repo> release|operational` against the repo by name, + then `apply` for what it reports, never from a carried copy. +6. **Intent files last, and by hand,** since nothing mechanical judges these. + +Reconcile the registry entry (`status`, `types`, `releaseTrigger`, `workflowModel`, +`driftNotes`) in the same pass, and delete a `driftNote` describing work this pass just finished +rather than leaving it standing. + +## Ship it + +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, done means measured, not applied. diff --git a/.agents/skills/shell-codestyle/SKILL.md b/.agents/skills/shell-codestyle/SKILL.md index 5064582d..50b1f604 100644 --- a/.agents/skills/shell-codestyle/SKILL.md +++ b/.agents/skills/shell-codestyle/SKILL.md @@ -1,55 +1,55 @@ ---- -name: shell-codestyle -description: >- - Governs Bash/shell script style for ptr727/ProjectTemplate fleet repos: when a bootstrap or - host-tool script may be shell instead of Python, the mandatory set -Eeuo pipefail header, the - pipefail-versus-early-reader pitfall, self-locating scripts, shellcheck cleanliness, and the - why-not-what comment rule. Use this whenever writing, reviewing, or editing a .sh file, whenever - deciding whether a new script should be Bash or Python, or whenever a pipeline built from - `curl`/`grep`/`jq`-style commands looks like it silently swallowed a failure. Triggers even when - the task looks like a one-line tweak to an existing script, because a missing `-e`/`pipefail`, - or a reader piped straight from a producer that closes the pipe early, are each invisible until - the exact failure mode they guard against actually happens. Fleet-wide: a shell script can - appear in any repo (a bootstrap that installs the interpreter, a host tool that must run before - a toolchain exists), not only a repo whose primary language is shell. ---- - -# Shell Codestyle - -## Why this exists - -This is the shell-specific half of the fleet's code style guide, kept in one place instead of -re-derived per repo or per session. Shell is the fleet's exception language, reached for only -where Python cannot run yet, so its rules exist to keep that narrow surface safe rather than to -cover general scripting style. - -## When shell, not Python - -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. - -## Rules - -- **`set -Eeuo pipefail`, before the first command the script runs.** A header comment sits above - it, as `repo-config/configure.sh` and the `host-setup/` scripts do, since what matters is that - nothing executes unguarded rather than which line number it lands on. Without `-e` a failed - command in the middle of a sequence lets the rest run against a state nobody checked, and - without `pipefail` a pipeline reports the exit of its last stage, so a fetch that failed reads - as an answer when a parser downstream succeeds on an empty input. `-E` carries an `ERR` trap - into functions and command substitutions, so a script that later adds one is not surprised by - where it does not fire. -- **A reader that stops early needs its producer read first.** Under `pipefail`, a producer - writing to a closed pipe exits non-zero, so `curl ... | grep -q` reports a successful fetch as - a failure whenever the match is found early enough. Capture the output, then search it. -- **Self-locating, never dependent on the caller's directory.** A script resolves its own - directory from `BASH_SOURCE` and references its payloads through it, since the working - directory at invocation is not a property of the script. -- **`shellcheck` clean, and a deliberate exception carries its reason inline.** A - `# shellcheck disable=SCxxxx` names why the rule does not apply here, so the next reader can - tell a considered exception from an unread warning. `repo-config/configure.sh` is the worked - example, carrying five `SC2016` disables where a single-quoted `jq` program must stay - unexpanded, each with its reason on the same line. -- **Comments say why, never what.** The code states what it does. A comment restating it goes - stale silently, where a comment carrying a reason fails visibly when the reason stops being - true. +--- +name: shell-codestyle +description: >- + Governs Bash/shell script style for ptr727/ProjectTemplate fleet repos: when a bootstrap or + host-tool script may be shell instead of Python, the mandatory set -Eeuo pipefail header, the + pipefail-versus-early-reader pitfall, self-locating scripts, shellcheck cleanliness, and the + why-not-what comment rule. Use this whenever writing, reviewing, or editing a .sh file, whenever + deciding whether a new script should be Bash or Python, or whenever a pipeline built from + `curl`/`grep`/`jq`-style commands looks like it silently swallowed a failure. Triggers even when + the task looks like a one-line tweak to an existing script, because a missing `-e`/`pipefail`, + or a reader piped straight from a producer that closes the pipe early, are each invisible until + the exact failure mode they guard against actually happens. Fleet-wide: a shell script can + appear in any repo (a bootstrap that installs the interpreter, a host tool that must run before + a toolchain exists), not only a repo whose primary language is shell. +--- + +# Shell Codestyle + +## Why this exists + +This is the shell-specific half of the fleet's code style guide, kept in one place instead of +re-derived per repo or per session. Shell is the fleet's exception language, reached for only +where Python cannot run yet, so its rules exist to keep that narrow surface safe rather than to +cover general scripting style. + +## When shell, not Python + +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. + +## Rules + +- **`set -Eeuo pipefail`, before the first command the script runs.** A header comment sits above + it, as `repo-config/configure.sh` and the `host-setup/` scripts do, since what matters is that + nothing executes unguarded rather than which line number it lands on. Without `-e` a failed + command in the middle of a sequence lets the rest run against a state nobody checked, and + without `pipefail` a pipeline reports the exit of its last stage, so a fetch that failed reads + as an answer when a parser downstream succeeds on an empty input. `-E` carries an `ERR` trap + into functions and command substitutions, so a script that later adds one is not surprised by + where it does not fire. +- **A reader that stops early needs its producer read first.** Under `pipefail`, a producer + writing to a closed pipe exits non-zero, so `curl ... | grep -q` reports a successful fetch as + a failure whenever the match is found early enough. Capture the output, then search it. +- **Self-locating, never dependent on the caller's directory.** A script resolves its own + directory from `BASH_SOURCE` and references its payloads through it, since the working + directory at invocation is not a property of the script. +- **`shellcheck` clean, and a deliberate exception carries its reason inline.** A + `# shellcheck disable=SCxxxx` names why the rule does not apply here, so the next reader can + tell a considered exception from an unread warning. `repo-config/configure.sh` is the worked + example, carrying five `SC2016` disables where a single-quoted `jq` program must stay + unexpanded, each with its reason on the same line. +- **Comments say why, never what.** The code states what it does. A comment restating it goes + stale silently, where a comment carrying a reason fails visibly when the reason stops being + true. diff --git a/.agents/skills/skill-lifecycle/SKILL.md b/.agents/skills/skill-lifecycle/SKILL.md index 92972b0f..85a73c51 100644 --- a/.agents/skills/skill-lifecycle/SKILL.md +++ b/.agents/skills/skill-lifecycle/SKILL.md @@ -1,49 +1,49 @@ ---- -name: skill-lifecycle -description: >- - Governs the lifecycle of the fleet's own skills in ptr727/ProjectTemplate: creating, changing, splitting, and retiring a skill under .agents/skills/, the source-versus-generated split with .claude-plugin/, the regenerate and --check semantics of scripts/build_dist.py, the install and stamp semantics of scripts/skills_install.py, the doc-packaging pattern that keeps a law doc and its skill in agreement, and the trigger-description conventions that make a skill fire. Use this whenever about to create, edit, move, or delete anything under .agents/skills/ or .claude-plugin/, whenever packaging a doc or a doc section as a skill, and whenever deciding whether a topic deserves a skill at all. Triggers even when the edit looks trivial, such as fixing a typo in one SKILL.md, because the generated plugin desyncs the moment the source changes without a build_dist.py run, and CI fails the pull request on exactly that. Hub-context only, since .agents/skills/ exists only in the hub. ---- - -# Skill Lifecycle - -## Why This Exists - -The agent most likely to get a skill wrong is the one editing a skill, and before this skill existed nothing watched that moment: the regenerate and install semantics lived in `scripts/` docstrings and scattered prose, so the procedure was rediscovered per session. The two standing hazards are mechanical and silent. A hand-edit to the generated `.claude-plugin/` tree is overwritten by the next regenerate, and a source edit without a regenerate ships a plugin that no longer matches its source, which the CI `--check` gate fails rather than anyone noticing in review. - -## The Pipeline - -- **`.agents/skills/<name>/SKILL.md` is the only hand-authored source**, with optional `references/` and `scripts/` directories beside it. Codex and opencode read this tree directly, project-local, and also read the global `~/.agents/skills/` copy the installer materializes. -- **Claude Code never scans that path.** `scripts/build_dist.py` generates a Claude-plugin-compatible copy at `.claude-plugin/fleet-skills/`, published through `.claude-plugin/marketplace.json`. The generated tree is never hand-edited, and `build_dist.py --check` exits non-zero when it was built from different source bytes than `.agents/skills/` currently holds. -- **The skill set is implicit.** Every `.agents/skills/<name>/` directory carrying a `SKILL.md` is a skill, and the generated `plugin.json` derives its list from those directories, so adding or retiring a skill edits no manifest by hand. `marketplace.json` names the plugin, not the skills, and is untouched by ordinary lifecycle work. -- **`scripts/skills_install.py`, run from a hub checkout, installs both forms per machine**: an overlay copy into `~/.agents/skills/` for Codex and opencode, marked per skill so a retired skill is removed on the next run and a foreign skill is never touched, and a user-scope plugin install for Claude Code via the `claude` CLI. Each run stamps the hub commit into `~/.agents/skills-install-stamp.json`, and `--report` reads that stamp against the checkout and exits non-zero when the machine is behind. The install is global per user, and per-repo pinning is a settled non-goal (`docs/fleet-map.md` "Skills Install Model"). - -## Deciding a Topic Deserves a Skill - -A skill surfaces at a trigger moment. A rule that binds every action all the time, or a short reference section a task reads once, gains nothing from being one: the always-on layer is the carried instruction set (`AGENTS.md` and the sections it maps), and packaging it as a skill duplicates it and spends the tokens the delegation rules exist to save. The `AGENTS.md` "Where the Rules Live" map records the disposition either way, a skill annotation on the row or the deliberate absence of one, so a topic with no skill reads as a decision rather than an oversight. - -## 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. -3. **Author the body per the `comment-and-doc-style` skill**: CRLF (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 both trees together**: `python3 scripts/build_dist.py`, then commit the source and the regenerated `.claude-plugin/` in one commit. CI runs `--check` on every pull request and fails a desynced pair. `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. - -## Changing or Retiring a Skill - -- **Edit only the source tree.** Any content change under `.claude-plugin/` that did not come from a `build_dist.py` run is a defect, whatever it fixes. -- **Retiring is deleting the source directory and regenerating.** The derived `plugin.json` list shrinks with it, and the installer's per-skill markers remove the retired skill from `~/.agents/skills/` on each machine's next run. -- **A deletion sweeps the prose that references the skill**, in the same change rather than as follow-up: the `AGENTS.md` map row or paragraph naming it, any law-doc packaging pointer to it, and any sibling skill that disambiguates against it. A law-doc section that had moved its full rules into the skill takes them back, or is retired with it, so no rule is silently lost with the skill that carried it. -- **Renaming is a retire plus a create** as far as the installer's markers and the plugin list are concerned, so sweep references the same way. - -## The Doc-Packaging Pattern - -Packaging keeps one topic in one authoritative place while the skill makes it surface automatically. It has two shapes, and each pairing states which it uses: - -- **Moved content.** The law-doc section keeps a summary and the skill holds the full rules (`git-commit-conventions`, `comment-and-doc-style`, `pr-review-conduct`). The section ends with the standard pointer sentence: packaged as the named skill at `.agents/skills/<name>/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, read the skill for the full rules. -- **Kept authority.** The source doc keeps the full rules and the skill is the summary that routes to them (`audit-a-repo` over `AUDIT.md`, `workflow-ci-contract` over `WORKFLOW.md`, `agent-conduct` over its GOVERNANCE sections). The skill states per topic which doc section owns it. - -In both shapes the doc wins on any disagreement, and the skill is what needs fixing. A rule stated fully in both places is the drift this pattern exists to prevent, so an edit to a packaged rule lands in its owning place and the other side's summary is checked against it in the same change. +--- +name: skill-lifecycle +description: >- + Governs the lifecycle of the fleet's own skills in ptr727/ProjectTemplate: creating, changing, splitting, and retiring a skill under .agents/skills/, the source-versus-generated split with .claude-plugin/, the regenerate and --check semantics of scripts/build_dist.py, the install and stamp semantics of scripts/skills_install.py, the doc-packaging pattern that keeps a law doc and its skill in agreement, and the trigger-description conventions that make a skill fire. Use this whenever about to create, edit, move, or delete anything under .agents/skills/ or .claude-plugin/, whenever packaging a doc or a doc section as a skill, and whenever deciding whether a topic deserves a skill at all. Triggers even when the edit looks trivial, such as fixing a typo in one SKILL.md, because the generated plugin desyncs the moment the source changes without a build_dist.py run, and CI fails the pull request on exactly that. Hub-context only, since .agents/skills/ exists only in the hub. +--- + +# Skill Lifecycle + +## Why This Exists + +The agent most likely to get a skill wrong is the one editing a skill, and before this skill existed nothing watched that moment: the regenerate and install semantics lived in `scripts/` docstrings and scattered prose, so the procedure was rediscovered per session. The two standing hazards are mechanical and silent. A hand-edit to the generated `.claude-plugin/` tree is overwritten by the next regenerate, and a source edit without a regenerate ships a plugin that no longer matches its source, which the CI `--check` gate fails rather than anyone noticing in review. + +## The Pipeline + +- **`.agents/skills/<name>/SKILL.md` is the only hand-authored source**, with optional `references/` and `scripts/` directories beside it. Codex and opencode read this tree directly, project-local, and also read the global `~/.agents/skills/` copy the installer materializes. +- **Claude Code never scans that path.** `scripts/build_dist.py` generates a Claude-plugin-compatible copy at `.claude-plugin/fleet-skills/`, published through `.claude-plugin/marketplace.json`. The generated tree is never hand-edited, and `build_dist.py --check` exits non-zero when it was built from different source bytes than `.agents/skills/` currently holds. +- **The skill set is implicit.** Every `.agents/skills/<name>/` directory carrying a `SKILL.md` is a skill, and the generated `plugin.json` derives its list from those directories, so adding or retiring a skill edits no manifest by hand. `marketplace.json` names the plugin, not the skills, and is untouched by ordinary lifecycle work. +- **`scripts/skills_install.py`, run from a hub checkout, installs both forms per machine**: an overlay copy into `~/.agents/skills/` for Codex and opencode, marked per skill so a retired skill is removed on the next run and a foreign skill is never touched, and a user-scope plugin install for Claude Code via the `claude` CLI. Each run stamps the hub commit into `~/.agents/skills-install-stamp.json`, and `--report` reads that stamp against the checkout and exits non-zero when the machine is behind. The install is global per user, and per-repo pinning is a settled non-goal (`docs/fleet-map.md` "Skills Install Model"). + +## Deciding a Topic Deserves a Skill + +A skill surfaces at a trigger moment. A rule that binds every action all the time, or a short reference section a task reads once, gains nothing from being one: the always-on layer is the carried instruction set (`AGENTS.md` and the sections it maps), and packaging it as a skill duplicates it and spends the tokens the delegation rules exist to save. The `AGENTS.md` "Where the Rules Live" map records the disposition either way, a skill annotation on the row or the deliberate absence of one, so a topic with no skill reads as a decision rather than an oversight. + +## 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. +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 both trees together**: `python3 scripts/build_dist.py`, then commit the source and the regenerated `.claude-plugin/` in one commit. CI runs `--check` on every pull request and fails a desynced pair. `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. + +## Changing or Retiring a Skill + +- **Edit only the source tree.** Any content change under `.claude-plugin/` that did not come from a `build_dist.py` run is a defect, whatever it fixes. +- **Retiring is deleting the source directory and regenerating.** The derived `plugin.json` list shrinks with it, and the installer's per-skill markers remove the retired skill from `~/.agents/skills/` on each machine's next run. +- **A deletion sweeps the prose that references the skill**, in the same change rather than as follow-up: the `AGENTS.md` map row or paragraph naming it, any law-doc packaging pointer to it, and any sibling skill that disambiguates against it. A law-doc section that had moved its full rules into the skill takes them back, or is retired with it, so no rule is silently lost with the skill that carried it. +- **Renaming is a retire plus a create** as far as the installer's markers and the plugin list are concerned, so sweep references the same way. + +## The Doc-Packaging Pattern + +Packaging keeps one topic in one authoritative place while the skill makes it surface automatically. It has two shapes, and each pairing states which it uses: + +- **Moved content.** The law-doc section keeps a summary and the skill holds the full rules (`git-commit-conventions`, `comment-and-doc-style`, `pr-review-conduct`). The section ends with the standard pointer sentence: packaged as the named skill at `.agents/skills/<name>/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, read the skill for the full rules. +- **Kept authority.** The source doc keeps the full rules and the skill is the summary that routes to them (`audit-a-repo` over `AUDIT.md`, `workflow-ci-contract` over `WORKFLOW.md`, `agent-conduct` over its GOVERNANCE sections). The skill states per topic which doc section owns it. + +In both shapes the doc wins on any disagreement, and the skill is what needs fixing. A rule stated fully in both places is the drift this pattern exists to prevent, so an edit to a packaged rule lands in its owning place and the other side's summary is checked against it in the same change. diff --git a/.agents/skills/standup-a-repo/SKILL.md b/.agents/skills/standup-a-repo/SKILL.md index 4a992707..56c66a1e 100644 --- a/.agents/skills/standup-a-repo/SKILL.md +++ b/.agents/skills/standup-a-repo/SKILL.md @@ -1,99 +1,99 @@ ---- -name: standup-a-repo -description: >- - Drives STANDUP.md's procedure for taking a ptr727/ProjectTemplate fleet repo from nothing (or a - partial state) to operational against the fleet ground truth, run from a hub checkout for a - named target repo the maintainer is standing up. Use this whenever asked to stand up, create, - 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 - 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. ---- - -# Stand Up a Repo - -## Why this exists - -STANDUP.md's own section order exists because several of its steps close a window that cannot be -reopened cheaply: commit signing has to be correct before the first commit, the long-lived -branches have to exist before any standup commit lands on one, and the instruction set has to be -carried before anything else is authored against it. This skill exists so that order survives -contact with a real, time-pressured standup instead of depending on an agent remembering to run -each gate unprompted. It is a driver over STANDUP.md, not a replacement for it. Read STANDUP.md -itself for the full text of every step, the onboarding-a-new-repo-type procedure, and the -cold-start self-test. - -## Before starting - -Read STANDUP.md section 0A first. Nothing in this procedure creates the GitHub repository, its -App, or its secrets, each an outward-facing write that needs the maintainer's explicit permission -and inputs, so hand that checklist over before step 1 rather than discovering the gap partway -through. A repo with no remote is not partially stood up, it is not started, and only the -maintainer can supply what section 0A lists. - -## Apply, in order - -1. **Signing, before the first commit.** STANDUP.md section 0: verify, never set, the inherited - `--global` commit identity and signing configuration, and the host tool floors via - `python3 scripts/host_gate.py`. The window closes at the first commit, since a repo committed - under the wrong identity or unsigned cannot be cleanly repaired afterward. - -2. **Branches, before the first standup commit.** STANDUP.md section 0B: create `main` and - `develop` empty, off one signed empty root commit, then run every step below on a feature - branch off `develop`. Never commit standup work directly onto `develop`. `non_fast_forward` on - both branch payloads, or the missing blocking rule on an operational repo's `develop` ruleset, - makes that mistake either unrecoverable or silently unprotected. - -3. **Classify and catalog.** STANDUP.md section 1: resolve the repo's type(s) against `AUDIT.md` - section 2, then write or repair its `registry/repos.json` entry and confirm it with - `spec/validate.py`. - -4. **The instruction set, before authoring anything.** STANDUP.md section 1A: carry `AGENTS.md`, - `GOVERNANCE.md`, `CODESTYLE.md`, `WORKFLOW.md` and `AUDIT.md`, adapted rather than cloned for - the ones that describe a repo, plus `.markdownlint-cli2.jsonc` and `cspell.json`. Read - `CODESTYLE.md` and the `GOVERNANCE.md` documentation-style rules before writing any repo - content of your own, the same window-closes shape as signing in step 1. - -5. **Capture the source, if one exists.** STANDUP.md section 1B, only when the repo's content - replaces a live external system: capture it and verify the capture against the source before - anything is scaffolded from it, since the source is not under version control and cannot be - re-derived once it stops serving. - -6. **The baseline files.** STANDUP.md section 2: copy every `spec/files.json` entry whose - `appliesTo` matches the repo's selector set, adapted rather than cloned, and choose - `version.json`'s version floor deliberately rather than propagating the template's. Carry - `AGENTS.md`'s skill-dependency pointer paragraph, naming `scripts/skills_install.py` and where - the fleet's Skills live, as one more verbatim unit in this same step, not a separate pass, the - identical requirement `RESYNC.md` places on a repo already stood up. - -7. **The workflows.** STANDUP.md section 3: implement the Actions `WORKFLOW.md` requires for the - repo's type, reusing `catalog/snippets/workflows/` as the reference implementation rather than - inventing a shape. - -8. **Settings, rulesets, and secrets.** STANDUP.md section 4: confirm the remote and the GitHub - repository agree before running anything else here, then apply with - `repo-config/configure.sh apply owner/repo release|operational` and check with the same - command's `check` subcommand, never from a hand-built or carried copy. - -9. **Verify with the audit.** STANDUP.md section 5: run `AUDIT.md` end to end. The repo is stood - up only when it passes for its type, or its residual deltas are tracked in - `reports/<repo>/audit.md` plus an issue. - -## Onboarding a new repo type - -When a repo matches no existing type in `spec/project-types.json`, that is a type to onboard, not -a repo to force into the nearest existing one. STANDUP.md's "Onboarding a New Repo Type" section -covers the manifest additions (`spec/project-types.json`, `spec/files.json`, `spec/secrets.json`, -`spec/scope-model.md`, `spec/type-model.md`, and the `registry/repos.schema.json` target enum for -a new publish destination) and the cold-start self-test that proves the result usable by a -context-free agent, not just by the one that wrote it. - -## Ship it - -One pull request per standup, branched from `develop` per step 2 above, into `develop`, never a -direct push to a protected branch. 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. +--- +name: standup-a-repo +description: >- + Drives STANDUP.md's procedure for taking a ptr727/ProjectTemplate fleet repo from nothing (or a + partial state) to operational against the fleet ground truth, run from a hub checkout for a + named target repo the maintainer is standing up. Use this whenever asked to stand up, create, + 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 + 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. +--- + +# Stand Up a Repo + +## Why this exists + +STANDUP.md's own section order exists because several of its steps close a window that cannot be +reopened cheaply: commit signing has to be correct before the first commit, the long-lived +branches have to exist before any standup commit lands on one, and the instruction set has to be +carried before anything else is authored against it. This skill exists so that order survives +contact with a real, time-pressured standup instead of depending on an agent remembering to run +each gate unprompted. It is a driver over STANDUP.md, not a replacement for it. Read STANDUP.md +itself for the full text of every step, the onboarding-a-new-repo-type procedure, and the +cold-start self-test. + +## Before starting + +Read STANDUP.md section 0A first. Nothing in this procedure creates the GitHub repository, its +App, or its secrets, each an outward-facing write that needs the maintainer's explicit permission +and inputs, so hand that checklist over before step 1 rather than discovering the gap partway +through. A repo with no remote is not partially stood up, it is not started, and only the +maintainer can supply what section 0A lists. + +## Apply, in order + +1. **Signing, before the first commit.** STANDUP.md section 0: verify, never set, the inherited + `--global` commit identity and signing configuration, and the host tool floors via + `python3 scripts/host_gate.py`. The window closes at the first commit, since a repo committed + under the wrong identity or unsigned cannot be cleanly repaired afterward. + +2. **Branches, before the first standup commit.** STANDUP.md section 0B: create `main` and + `develop` empty, off one signed empty root commit, then run every step below on a feature + branch off `develop`. Never commit standup work directly onto `develop`. `non_fast_forward` on + both branch payloads, or the missing blocking rule on an operational repo's `develop` ruleset, + makes that mistake either unrecoverable or silently unprotected. + +3. **Classify and catalog.** STANDUP.md section 1: resolve the repo's type(s) against `AUDIT.md` + section 2, then write or repair its `registry/repos.json` entry and confirm it with + `spec/validate.py`. + +4. **The instruction set, before authoring anything.** STANDUP.md section 1A: carry `AGENTS.md`, + `GOVERNANCE.md`, `CODESTYLE.md`, `WORKFLOW.md` and `AUDIT.md`, adapted rather than cloned for + the ones that describe a repo, plus `.markdownlint-cli2.jsonc` and `cspell.json`. Read + `CODESTYLE.md` and the `GOVERNANCE.md` documentation-style rules before writing any repo + content of your own, the same window-closes shape as signing in step 1. + +5. **Capture the source, if one exists.** STANDUP.md section 1B, only when the repo's content + replaces a live external system: capture it and verify the capture against the source before + anything is scaffolded from it, since the source is not under version control and cannot be + re-derived once it stops serving. + +6. **The baseline files.** STANDUP.md section 2: copy every `spec/files.json` entry whose + `appliesTo` matches the repo's selector set, adapted rather than cloned, and choose + `version.json`'s version floor deliberately rather than propagating the template's. Carry + `AGENTS.md`'s skill-dependency pointer paragraph, naming `scripts/skills_install.py` and where + the fleet's Skills live, as one more verbatim unit in this same step, not a separate pass, the + identical requirement `RESYNC.md` places on a repo already stood up. + +7. **The workflows.** STANDUP.md section 3: implement the Actions `WORKFLOW.md` requires for the + repo's type, reusing `catalog/snippets/workflows/` as the reference implementation rather than + inventing a shape. + +8. **Settings, rulesets, and secrets.** STANDUP.md section 4: confirm the remote and the GitHub + repository agree before running anything else here, then apply with + `repo-config/configure.sh apply owner/repo release|operational` and check with the same + command's `check` subcommand, never from a hand-built or carried copy. + +9. **Verify with the audit.** STANDUP.md section 5: run `AUDIT.md` end to end. The repo is stood + up only when it passes for its type, or its residual deltas are tracked in + `reports/<repo>/audit.md` plus an issue. + +## Onboarding a new repo type + +When a repo matches no existing type in `spec/project-types.json`, that is a type to onboard, not +a repo to force into the nearest existing one. STANDUP.md's "Onboarding a New Repo Type" section +covers the manifest additions (`spec/project-types.json`, `spec/files.json`, `spec/secrets.json`, +`spec/scope-model.md`, `spec/type-model.md`, and the `registry/repos.schema.json` target enum for +a new publish destination) and the cold-start self-test that proves the result usable by a +context-free agent, not just by the one that wrote it. + +## Ship it + +One pull request per standup, branched from `develop` per step 2 above, into `develop`, never a +direct push to a protected branch. 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. diff --git a/.agents/skills/upstream-contribution-workflow/SKILL.md b/.agents/skills/upstream-contribution-workflow/SKILL.md index 6c8ff84d..3f88062a 100644 --- a/.agents/skills/upstream-contribution-workflow/SKILL.md +++ b/.agents/skills/upstream-contribution-workflow/SKILL.md @@ -1,80 +1,80 @@ ---- -name: upstream-contribution-workflow -description: >- - Governs how the maintainer contributes to a third-party repository he does not control (for - example esphome/esphome), distinct from the fleet's own internal branching model: a dirty work - branch on his own fork for the actual work and review iteration, squashed once clean to a second - branch that carries only the intended minimal history, that clean branch opened as the PR - against the upstream repo, and reviewer feedback applied to the dirty branch first, then - re-squashed into the clean one. Use this whenever about to open a pull request against a - repository outside the ptr727 fleet, whenever forking a third-party project to contribute a fix - or feature, whenever an upstream reviewer requests changes on a PR opened this way, and whenever - deciding which issue or PR template to use for a third-party repository. Triggers regardless of - the target repo's own type or workflow model, since this skill is about the shape of a - contribution to someone else's repo, not the target repo's own internal conventions, which this - skill does not attempt to state and are never assumed to match the fleet's. ---- - -# Upstream Contribution Workflow - -## Why this exists - -The fleet's own branching model (`operational-vs-release-workflow`) 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 -cycles, and the history that lands there should read as a deliberate, minimal contribution, not as -the maintainer's own iteration log. This skill is that different shape, kept separate from the -fleet's internal model so the two are never conflated. - -## The two-branch shape - -1. **Fork the upstream repo**, if not already forked. -2. **Do the actual work on a dirty work branch**, on the maintainer's own fork. This branch is - allowed to be messy: false starts, fixup commits, back-and-forth in response to review, whatever - the real work looks like while it's happening. Open a PR from this branch into a branch on the - maintainer's **own fork** (not upstream), so all the iteration happens there, visible and - reviewable, without touching the upstream repo at all. -3. **Once the dirty branch is clean and the change is ready, squash it to a second branch** that - carries only the intended, minimal commit history, one commit (or a small, deliberate set) that - states what the change is, not how it was arrived at. -4. **Open the PR against the upstream repo from that second, clean branch.** This is the only - branch upstream ever sees. -5. **If upstream reviewers ask for changes, apply them to the dirty branch first**, iterate there - the same way as step 2, then re-squash the updated dirty branch into the clean branch that - actually reaches upstream. Updating the same upstream PR rather than opening a new one each - round rewrites the clean branch's history, and pushing a rewritten branch that is already - published requires `git push --force-with-lease` (prefer it over a bare `--force`, it refuses - the push if the remote moved since the last fetch). **`git-commit-conventions`'s never-force-push - rule governs this fleet's own repos, where a branch is shared with bots, other branches, and - required-check history a rewrite would orphan, and it stays absolute there, with no exception. - It has no jurisdiction here**: this clean presentation branch lives on the maintainer's own - fork, outside the fleet entirely, and carries nobody's work but this squash. Force-with-lease - is scoped just as tightly regardless: only this one branch, only on the maintainer's own fork, - never the dirty work branch, which is the append-only iteration log this whole workflow exists - to preserve. If force-with-lease is ever refused or unavailable, open a fresh PR from a newly - named clean branch rather than fighting the push. - -**The dirty branch is always the working copy. The clean branch is always the presentation copy.** -Never reverse this: never iterate directly on the branch that's open against upstream, and never -skip the squash step because the dirty branch "looks clean enough." - -## Use the upstream repo's own conventions, not the fleet's - -Always use the upstream repo's own issue and PR templates, its own contribution guidelines, and -its own commit-message and code-style conventions when they differ from this fleet's. The fleet's -`comment-and-doc-style`, `git-commit-conventions`, and `pr-review-conduct` skills describe how -*this fleet* does things, and none of them are the target repository's own rules. Read the target -repo's `CONTRIBUTING.md` (or equivalent) and follow it. Where the target repo states no -convention of its own, matching the surrounding code's existing style in that file is the better -default, not falling back to the fleet's own convention by habit. - -## What stays governed by the fleet's own rules - -Signing commits and using the correct git identity are host configuration, not project -convention, so `git-commit-conventions`'s signing and identity rules still apply on both the dirty -and clean branches. They are properties of the committer, not of the target repository. The -write-safety rules (never write to a repository outside explicit authorization, never fabricate a -GitHub id) also still apply in full. A fork the maintainer owns is within scope to push to, and -the upstream repository itself is written to only through the PR the maintainer explicitly asked -for. +--- +name: upstream-contribution-workflow +description: >- + Governs how the maintainer contributes to a third-party repository he does not control (for + example esphome/esphome), distinct from the fleet's own internal branching model: a dirty work + branch on his own fork for the actual work and review iteration, squashed once clean to a second + branch that carries only the intended minimal history, that clean branch opened as the PR + against the upstream repo, and reviewer feedback applied to the dirty branch first, then + re-squashed into the clean one. Use this whenever about to open a pull request against a + repository outside the ptr727 fleet, whenever forking a third-party project to contribute a fix + or feature, whenever an upstream reviewer requests changes on a PR opened this way, and whenever + deciding which issue or PR template to use for a third-party repository. Triggers regardless of + the target repo's own type or workflow model, since this skill is about the shape of a + contribution to someone else's repo, not the target repo's own internal conventions, which this + skill does not attempt to state and are never assumed to match the fleet's. +--- + +# Upstream Contribution Workflow + +## Why this exists + +The fleet's own branching model (`operational-vs-release-workflow`) 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 +cycles, and the history that lands there should read as a deliberate, minimal contribution, not as +the maintainer's own iteration log. This skill is that different shape, kept separate from the +fleet's internal model so the two are never conflated. + +## The two-branch shape + +1. **Fork the upstream repo**, if not already forked. +2. **Do the actual work on a dirty work branch**, on the maintainer's own fork. This branch is + allowed to be messy: false starts, fixup commits, back-and-forth in response to review, whatever + the real work looks like while it's happening. Open a PR from this branch into a branch on the + maintainer's **own fork** (not upstream), so all the iteration happens there, visible and + reviewable, without touching the upstream repo at all. +3. **Once the dirty branch is clean and the change is ready, squash it to a second branch** that + carries only the intended, minimal commit history, one commit (or a small, deliberate set) that + states what the change is, not how it was arrived at. +4. **Open the PR against the upstream repo from that second, clean branch.** This is the only + branch upstream ever sees. +5. **If upstream reviewers ask for changes, apply them to the dirty branch first**, iterate there + the same way as step 2, then re-squash the updated dirty branch into the clean branch that + actually reaches upstream. Updating the same upstream PR rather than opening a new one each + round rewrites the clean branch's history, and pushing a rewritten branch that is already + published requires `git push --force-with-lease` (prefer it over a bare `--force`, it refuses + the push if the remote moved since the last fetch). **`git-commit-conventions`'s never-force-push + rule governs this fleet's own repos, where a branch is shared with bots, other branches, and + required-check history a rewrite would orphan, and it stays absolute there, with no exception. + It has no jurisdiction here**: this clean presentation branch lives on the maintainer's own + fork, outside the fleet entirely, and carries nobody's work but this squash. Force-with-lease + is scoped just as tightly regardless: only this one branch, only on the maintainer's own fork, + never the dirty work branch, which is the append-only iteration log this whole workflow exists + to preserve. If force-with-lease is ever refused or unavailable, open a fresh PR from a newly + named clean branch rather than fighting the push. + +**The dirty branch is always the working copy. The clean branch is always the presentation copy.** +Never reverse this: never iterate directly on the branch that's open against upstream, and never +skip the squash step because the dirty branch "looks clean enough." + +## Use the upstream repo's own conventions, not the fleet's + +Always use the upstream repo's own issue and PR templates, its own contribution guidelines, and +its own commit-message and code-style conventions when they differ from this fleet's. The fleet's +`comment-and-doc-style`, `git-commit-conventions`, and `pr-review-conduct` skills describe how +*this fleet* does things, and none of them are the target repository's own rules. Read the target +repo's `CONTRIBUTING.md` (or equivalent) and follow it. Where the target repo states no +convention of its own, matching the surrounding code's existing style in that file is the better +default, not falling back to the fleet's own convention by habit. + +## What stays governed by the fleet's own rules + +Signing commits and using the correct git identity are host configuration, not project +convention, so `git-commit-conventions`'s signing and identity rules still apply on both the dirty +and clean branches. They are properties of the committer, not of the target repository. The +write-safety rules (never write to a repository outside explicit authorization, never fabricate a +GitHub id) also still apply in full. A fork the maintainer owns is within scope to push to, and +the upstream repository itself is written to only through the PR the maintainer explicitly asked +for. diff --git a/.agents/skills/workflow-ci-contract/SKILL.md b/.agents/skills/workflow-ci-contract/SKILL.md index 8160ce98..685a4cac 100644 --- a/.agents/skills/workflow-ci-contract/SKILL.md +++ b/.agents/skills/workflow-ci-contract/SKILL.md @@ -1,47 +1,47 @@ ---- -name: workflow-ci-contract -description: >- - Governs the WORKFLOW.md CI/CD behavioral contract for every ptr727/ProjectTemplate fleet repo: the D1-D9 guarantees stated as the failure mode each prevents, the seam contract for release assets, the artifact lifecycle, NBGV versioning and classification, validate-at-entry, and the 5A/5B/5C test methodology with its per-type walkthroughs. Use this whenever writing or editing anything under .github/workflows/, adding or dropping a release target, auditing a repo's workflows, or reasoning about why a publish did or did not fire. This is the YAML half of the pipeline, and the operational-vs-release-workflow skill keeps the git half (branching, promotion, publish policy), so branch choice questions go there. Triggers even when the edit looks mechanical, such as bumping an action, renaming a job, or adding one upload step, because SHA pinning, the ruleset-bound aggregator name, smoke gating on uploads, and retention-days are each easy to break in a one-line diff that no smoke build exercises, since workflow-only changes are deliberately not smoke-built. WORKFLOW.md keeps authority, and GOVERNANCE.md wins where the two overlap. ---- - -# Workflow CI Contract - -## Why This Exists - -`WORKFLOW.md` in the hub is the largest law doc, a behavioral contract stating required outcomes rather than a required implementation, and it had no skill surface, so agents edited workflow YAML without the contract in view. This skill is the summary plus the binding rules, with the guarantee catalog and the test methodology split into `references/`. `WORKFLOW.md` keeps authority for the contract and methodology, and `GOVERNANCE.md` ("Workflow YAML Conventions", "Release Model") wins where the two overlap. - -## How the Contract Is Read - -- **Outcomes, not bytes.** A workflow is correct when it satisfies the section 4 contract against the expected inputs and outputs, not when it matches a catalog snippet byte for byte. Two repos may implement one guarantee with different YAML. -- **Applicability.** A guarantee governing a construct the repo does not contain is N/A: recorded, excluded from the verdict, never a defect. A source-only pipeline is mostly N/A and that is fine. -- **Operational is binary.** Every applicable guarantee holds, or the workflow is not operational. A single applicable input-output mismatch is a defect regardless of how clean the YAML looks. -- **Reached, not carried.** A standard workflow whose job graph is identical across repos of a type is a `workflow_call` task the hub hosts once, and a repo carries only a caller stub pinned to a hub release commit plus a composite-action hook at `.github/actions/<hook>` for what is its own. The merge-bot is the first, and `docs/reusable-workflows.md` in the hub carries the model, the hook contract, and the phase each workflow migrates in. Until a workflow's phase ships, its copy is graded as below. -- **Two layers.** Orchestration (the PR entry workflow, publisher, version/release/badge jobs) is generic and standard at the job level. Build leaves (`build-<target>-task.yml`) are repo-owned. Inputs like `github`/`nuget`/`dockerhub`/`expect_release_assets` live on the orchestrator, a leaf only receives `ref`/`branch`/`smoke` and a derived `push`, so assert each input in the layer that declares it. What a repo curates is the list of targets, and adding or dropping one edits the whole surface together: the `enable_<target>` input, the `build-<target>` job and its `github-release` `needs:` entry, the `changes` paths-filter entry and output, and the `smoke-build` enable-forward (D6.4). - -## Style Rules That Break in One-Line Diffs - -- **Pin every action to a commit SHA** with a trailing `# vX.Y.Z` comment, first-party included. The one documented no-pin exception is `dotnet/nbgv@master`. Invent no others. -- **Names carry meaning**: `-task.yml` files and "task" names are reusable (`on: workflow_call`), entry points end in what they do and their names end in "action", every job `name:` ends in "job" and every step in "step". A ruleset-bound required check's job `name:` and the ruleset `context:` are one string renamed together, in the live ruleset and `repo-config/{develop,main}.json` in lockstep, or required-check enforcement silently breaks. -- **Concurrency**: top-level workflows use `group: '${{ github.workflow }}-${{ github.ref }}'` with `cancel-in-progress: true`. The publisher is the documented exception: a global ref-independent group with `cancel-in-progress: false`, so publishes serialize and never cancel mid-push. -- **Shells**: every multi-line bash `run:` starts `set -Eeuo pipefail`. Multi-line `if:` uses `>-`, never `|`. -- **Boolean inputs** are declared in both trigger blocks and compared against both forms, `${{ inputs.foo == true || inputs.foo == 'true' }}`, since `workflow_dispatch` delivers strings. -- **Permissions validate before `if:`**, so even a skipped job needs valid `permissions:`, and a callee's extra scope (`actions: write`, `id-token: write`) is granted by the caller at the one entry point that needs it. -- **Chaining across optional jobs** allowlists `success`/`skipped` explicitly, because `!= 'failure'` lets `cancelled` through. -- **Docker layer cache** targets a registry tag (`buildcache-<branch>`), never `type=gha`. -- **Workflow YAML is LF.** Preserve endings on every edit. - -## The Core Behavioral Spine - -- **PRs validate fast and never publish**: a paths-filter smoke-builds only changed targets, a type-appropriate validation job always runs, and one required aggregator gates the merge, treating skipped smoke as pass and blocking on failure or cancelled. Smoke does a full compile/lint/test but pushes nothing and uploads nothing, every `upload-artifact` gated `!smoke`. -- **A human merge never auto-publishes**: a `plan` job decides once and every job gates on it. Publishes come from a code-affecting bot push to `main`, a manual dispatch of `main` or `develop`, or the main-only weekly Docker schedule. Each run builds the one trigger branch, `main` a clean `X.Y.Z`, anything else a prerelease `X.Y.Z-g<sha>`, with NBGV owning the patch from git height. The release tags the built commit's SHA (`GitCommitId`), never a branch name. -- **Validate at entry**: cross-input and input-versus-derived-state invariants are asserted once in a dedicated entry job the downstream jobs `needs:`, failing fast with `::error::` before expensive work. The release gate checks branch-versus-prerelease in both directions, strips `+buildmetadata`, and on smoke skips the check while the job still succeeds. -- **The seam contract**: a target contributes a release file by uploading `release-asset-<branch>-<target>`, and the release job collects by `pattern:` plus `merge-multiple:`, never `artifact-ids:`, canonical even for a single target. A repo with no file target passes `expect_release_assets: false` at the caller. -- **Artifacts are an intra-run handoff**: consume-then-delete at the point of consumption, gated to the consumer's condition, best-effort, `retention-days: 1` on every upload as the backstop, and never a blanket delete of the run's artifact set, which destroys the diagnostics you need when the run fails. -- **No-op republish**: an unchanged version re-pushes nothing, the release-create step skips when the tag exists, registries dedupe server-side (`--skip-duplicate`, `skip-existing: true`), and Docker alone always re-pushes by design. -- **A build failure blocks every publish target**: `github-release` needs every build, and the terminal registry pusher guards with `!failure() && !cancelled()`, so nothing partial ships. - -The full catalog, each guarantee with the failure mode it prevents, is in `references/d-guarantees.md`. Auditing, tracing, and probing a repo's workflows is `references/test-methodology.md`. - -## After Any Workflow Edit - -Workflow-only changes are not smoke-built, so run actionlint locally (the Docker invocation in `GOVERNANCE.md` "Running the Linters Locally", which bundles shellcheck for `run:` blocks) before pushing, and remember a workflow change is only fully exercised by CI, since `secrets: inherit`, `permissions:`, and `needs:` wiring resolve only in a real run. +--- +name: workflow-ci-contract +description: >- + Governs the WORKFLOW.md CI/CD behavioral contract for every ptr727/ProjectTemplate fleet repo: the D1-D9 guarantees stated as the failure mode each prevents, the seam contract for release assets, the artifact lifecycle, NBGV versioning and classification, validate-at-entry, and the 5A/5B/5C test methodology with its per-type walkthroughs. Use this whenever writing or editing anything under .github/workflows/, adding or dropping a release target, auditing a repo's workflows, or reasoning about why a publish did or did not fire. This is the YAML half of the pipeline, and the operational-vs-release-workflow skill keeps the git half (branching, promotion, publish policy), so branch choice questions go there. Triggers even when the edit looks mechanical, such as bumping an action, renaming a job, or adding one upload step, because SHA pinning, the ruleset-bound aggregator name, smoke gating on uploads, and retention-days are each easy to break in a one-line diff that no smoke build exercises, since workflow-only changes are deliberately not smoke-built. WORKFLOW.md keeps authority, and GOVERNANCE.md wins where the two overlap. +--- + +# Workflow CI Contract + +## Why This Exists + +`WORKFLOW.md` in the hub is the largest law doc, a behavioral contract stating required outcomes rather than a required implementation, and it had no skill surface, so agents edited workflow YAML without the contract in view. This skill is the summary plus the binding rules, with the guarantee catalog and the test methodology split into `references/`. `WORKFLOW.md` keeps authority for the contract and methodology, and `GOVERNANCE.md` ("Workflow YAML Conventions", "Release Model") wins where the two overlap. + +## How the Contract Is Read + +- **Outcomes, not bytes.** A workflow is correct when it satisfies the section 4 contract against the expected inputs and outputs, not when it matches a catalog snippet byte for byte. Two repos may implement one guarantee with different YAML. +- **Applicability.** A guarantee governing a construct the repo does not contain is N/A: recorded, excluded from the verdict, never a defect. A source-only pipeline is mostly N/A and that is fine. +- **Operational is binary.** Every applicable guarantee holds, or the workflow is not operational. A single applicable input-output mismatch is a defect regardless of how clean the YAML looks. +- **Reached, not carried.** A standard workflow whose job graph is identical across repos of a type is a `workflow_call` task the hub hosts once, and a repo carries only a caller stub pinned to a hub release commit plus a composite-action hook at `.github/actions/<hook>` for what is its own. The merge-bot is the first, and `docs/reusable-workflows.md` in the hub carries the model, the hook contract, and the phase each workflow migrates in. Until a workflow's phase ships, its copy is graded as below. +- **Two layers.** Orchestration (the PR entry workflow, publisher, version/release/badge jobs) is generic and standard at the job level. Build leaves (`build-<target>-task.yml`) are repo-owned. Inputs like `github`/`nuget`/`dockerhub`/`expect_release_assets` live on the orchestrator, a leaf only receives `ref`/`branch`/`smoke` and a derived `push`, so assert each input in the layer that declares it. What a repo curates is the list of targets, and adding or dropping one edits the whole surface together: the `enable_<target>` input, the `build-<target>` job and its `github-release` `needs:` entry, the `changes` paths-filter entry and output, and the `smoke-build` enable-forward (D6.4). + +## Style Rules That Break in One-Line Diffs + +- **Pin every action to a commit SHA** with a trailing `# vX.Y.Z` comment, first-party included. The one documented no-pin exception is `dotnet/nbgv@master`. Invent no others. +- **Names carry meaning**: `-task.yml` files and "task" names are reusable (`on: workflow_call`), entry points end in what they do and their names end in "action", every job `name:` ends in "job" and every step in "step". A ruleset-bound required check's job `name:` and the ruleset `context:` are one string renamed together, in the live ruleset and `repo-config/{develop,main}.json` in lockstep, or required-check enforcement silently breaks. +- **Concurrency**: top-level workflows use `group: '${{ github.workflow }}-${{ github.ref }}'` with `cancel-in-progress: true`. The publisher is the documented exception: a global ref-independent group with `cancel-in-progress: false`, so publishes serialize and never cancel mid-push. +- **Shells**: every multi-line bash `run:` starts `set -Eeuo pipefail`. Multi-line `if:` uses `>-`, never `|`. +- **Boolean inputs** are declared in both trigger blocks and compared against both forms, `${{ inputs.foo == true || inputs.foo == 'true' }}`, since `workflow_dispatch` delivers strings. +- **Permissions validate before `if:`**, so even a skipped job needs valid `permissions:`, and a callee's extra scope (`actions: write`, `id-token: write`) is granted by the caller at the one entry point that needs it. +- **Chaining across optional jobs** allowlists `success`/`skipped` explicitly, because `!= 'failure'` lets `cancelled` through. +- **Docker layer cache** targets a registry tag (`buildcache-<branch>`), never `type=gha`. +- **Workflow YAML is LF.** Preserve endings on every edit. + +## The Core Behavioral Spine + +- **PRs validate fast and never publish**: a paths-filter smoke-builds only changed targets, a type-appropriate validation job always runs, and one required aggregator gates the merge, treating skipped smoke as pass and blocking on failure or cancelled. Smoke does a full compile/lint/test but pushes nothing and uploads nothing, every `upload-artifact` gated `!smoke`. +- **A human merge never auto-publishes**: a `plan` job decides once and every job gates on it. Publishes come from a code-affecting bot push to `main`, a manual dispatch of `main` or `develop`, or the main-only weekly Docker schedule. Each run builds the one trigger branch, `main` a clean `X.Y.Z`, anything else a prerelease `X.Y.Z-g<sha>`, with NBGV owning the patch from git height. The release tags the built commit's SHA (`GitCommitId`), never a branch name. +- **Validate at entry**: cross-input and input-versus-derived-state invariants are asserted once in a dedicated entry job the downstream jobs `needs:`, failing fast with `::error::` before expensive work. The release gate checks branch-versus-prerelease in both directions, strips `+buildmetadata`, and on smoke skips the check while the job still succeeds. +- **The seam contract**: a target contributes a release file by uploading `release-asset-<branch>-<target>`, and the release job collects by `pattern:` plus `merge-multiple:`, never `artifact-ids:`, canonical even for a single target. A repo with no file target passes `expect_release_assets: false` at the caller. +- **Artifacts are an intra-run handoff**: consume-then-delete at the point of consumption, gated to the consumer's condition, best-effort, `retention-days: 1` on every upload as the backstop, and never a blanket delete of the run's artifact set, which destroys the diagnostics you need when the run fails. +- **No-op republish**: an unchanged version re-pushes nothing, the release-create step skips when the tag exists, registries dedupe server-side (`--skip-duplicate`, `skip-existing: true`), and Docker alone always re-pushes by design. +- **A build failure blocks every publish target**: `github-release` needs every build, and the terminal registry pusher guards with `!failure() && !cancelled()`, so nothing partial ships. + +The full catalog, each guarantee with the failure mode it prevents, is in `references/d-guarantees.md`. Auditing, tracing, and probing a repo's workflows is `references/test-methodology.md`. + +## After Any Workflow Edit + +Workflow-only changes are not smoke-built, so run actionlint locally (the Docker invocation in `GOVERNANCE.md` "Running the Linters Locally", which bundles shellcheck for `run:` blocks) before pushing, and remember a workflow change is only fully exercised by CI, since `secrets: inherit`, `permissions:`, and `needs:` wiring resolve only in a real run. diff --git a/.agents/skills/workflow-ci-contract/references/d-guarantees.md b/.agents/skills/workflow-ci-contract/references/d-guarantees.md index 4c3d5cc2..b41c99a6 100644 --- a/.agents/skills/workflow-ci-contract/references/d-guarantees.md +++ b/.agents/skills/workflow-ci-contract/references/d-guarantees.md @@ -1,70 +1,70 @@ -# The D-Guarantees, Condensed - -Each guarantee is a MUST from `WORKFLOW.md` section 4, stated as input to output plus the failure mode it prevents. This is the condensed catalog for working from, and `WORKFLOW.md` keeps authority, so read the section there when a guarantee's exact wording decides a verdict. - -## D1: PR Fast-Feedback (Smoke) - -- **D1.1** Only changed targets build: each target has a paths-filter entry, unchanged targets skip. Prevents a changed target slipping through unbuilt. -- **D1.2** A validation job always runs on any PR, and a non-.NET repo replaces it (never deletes it), re-pointing every `needs:` on it, the aggregator and `smoke-build` both. Prevents a PR merging with no validation, or a dangling `needs:` failing the workflow to load. -- **D1.3** Smoke never publishes and never uploads: full compile/lint/test, no pushes, every `upload-artifact` gated `!smoke`. Prevents a PR publishing and orphaned artifacts. -- **D1.4** Workflow-file changes are not smoke-built (the filter excludes `.github/workflows/**`), actionlint still validates them. -- **D1.5** One required aggregator gates merge: `needs:` the changes and validation jobs, passes on skipped smoke, blocks on failure or cancelled, and its name is ruleset-bound (job `name:` equals ruleset `context:`, renamed together). -- **D1.6** Coverage reports to Codecov for C# and Python repos with tests, best-effort so an outage never reds the gate, with a `codecov.yml` setting statuses informational and `.gitignore` excluding coverage output. - -## D2: Validation at Entry - -- **D2.1** A dedicated entry job asserts each cross-input invariant before expensive work, downstream jobs `needs:` it. -- **D2.2** The release gate fails loud when the default branch carries a prerelease suffix or a non-default branch carries none, strips `+buildmetadata` first, and on smoke skips the check while the job still succeeds (a job-level `if:` would skip dependents with it). -- **D2.3** A dispatch publish from any ref other than `main` or `develop` fails fast. -- **D2.4** Mutually-exclusive or must-pair inputs are validated, a half-filled combination fails fast. - -## D3: Versioning and Classification - -- **D3.1** One branch per run: `github.ref` names the built branch, NBGV classifies it directly, no `IGNORE_GITHUB_REF`. -- **D3.2** Default branch yields `X.Y.Z`, every other branch `X.Y.Z-g<sha>`, and the default-branch literal in the gate, the `prerelease` expression, and `version.json`'s `publicReleaseRefSpec` all name the repo's real default branch. -- **D3.3** `version.json` sets the major.minor floor, NBGV appends git height as the patch, and both are retained even by a no-compiler repo, since they own the tag. -- **D3.4** Registry versions follow the classification per registry: NuGet.org derives prerelease from the SemVer2 suffix, PyPI builds from `AssemblyFileVersion` with `.dev0` appended on `develop` only, and the develop build stays `--pre`-selectable above the released version. -- **D3.5** A wrapper repo drives its image version from a committed `name -> version` state file, and the leaf must actually read it, since a leaf still tagging off NBGV means the wrapper is not pinned to upstream. - -## D4: Release and Publish - -- **D4.1** Gated single-branch publish: a human merge never auto-publishes, the `plan` job decides once, publishes come from a code-affecting bot push to `main`, a dispatch of `main`/`develop`, or the main-only weekly Docker schedule. -- **D4.2** `target_commitish` is the built commit's SHA (NBGV `GitCommitId`), never a branch name and never `github.sha`. -- **D4.3** Every release is a tag plus source zip, README, and LICENSE, file targets attach `release-asset-*`, and a no-file-target caller passes `expect_release_assets: false` or the release-create step fails on unmatched files. -- **D4.4** No-op republish: an unchanged version re-pushes nothing, the release-create skips when the tag exists (refreshed only on `workflow_dispatch`), registries dedupe server-side, and Docker always re-pushes by design. -- **D4.5** A failed build blocks every publish target: `github-release` needs every build, the terminal registry pusher guards `!failure() && !cancelled()`, so nothing partial ships. -- **D4.6** A deploy check asserts which release and which environment answer, waiting for convergence to a bounded timeout, with an unreachable host reported distinctly from an HTTP status. - -## D5: Resource Cleanup - -- **D5.1** A cross-job transfer artifact is deleted at its point of consumption. An in-run intermediate may rely on the retention backstop. -- **D5.2** The delete runs under the same condition as its consumer, so a no-op re-run skips the release-asset delete while the PyPI build-artifact delete still runs. -- **D5.3** Cleanup is best-effort (`continue-on-error`, tolerate a failed listing, delete all matching ids). -- **D5.4** Every `upload-artifact` sets `retention-days: 1`. -- **D5.5** Never blanket-delete the run's artifacts, which destroys diagnostics and auto-emitted build records. -- **D5.6** A durable deploy destination's retention is bounded by a declared count with one side recorded as owning the prune: the deploy where its credential can observe the destination, the host where the credential is deliberately write-only. - -## D6: Seam Conformance - -- **D6.1** The release job downloads by `pattern:`/`merge-multiple:`, never `artifact-ids:`, canonical for single-target repos too. -- **D6.2** Branch-derived config reads `inputs.branch`, never `github.ref_name`. -- **D6.3** Artifact names are branch-suffixed. -- **D6.4** A target add or drop updates the whole surface together: `enable_<target>` input, `build-<target>` job, `github-release` `needs:` entry, paths-filter entry and output, and the `smoke-build` enable-forward. - -## D7: Concurrency, Permissions, Safety - -- **D7.1** The publisher serializes: global ref-independent concurrency group, `cancel-in-progress: false`. -- **D7.2** Every reusable job declares valid `permissions:` (validated before `if:`), a callee's extra scope granted by the caller. -- **D7.3** Boolean inputs are declared in both trigger blocks and compared against both forms. -- **D7.4** Optional-dependency chaining allowlists `success`/`skipped` explicitly. - -## D8: Bots and Automation - -- **D8.1** The merge-bot enables auto-merge on `opened`/`reopened` for every Dependabot tier, dispatches squash or merge by base ref, disables on a maintainer-pushed `synchronize`, and keys concurrency on the PR number, not `github.ref`. -- **D8.2** Codegen runs a deterministic matrix over both branches, Dependabot targets both branches. -- **D8.3** The upstream tracker writes a committed `name -> version` state file via a rolling per-branch bump PR the merge-bot auto-merges, and its branch prefix must match the merge-bot's head-ref pairs or auto-merge silently never fires. -- **D8.4** An identity allowlist used as a gate emits a `::warning::` on the non-matching branch rather than falling through silently, since a renamed App slug otherwise turns the gate off invisibly. - -## D9: Style and Static - -SHA pins with version comments, the name-suffix rules, `set -Eeuo pipefail`, `if: >-`, registry-tag Docker cache with `cache-to` only the built branch on push and `cache-from` both branches, line endings per `.editorconfig`. +# The D-Guarantees, Condensed + +Each guarantee is a MUST from `WORKFLOW.md` section 4, stated as input to output plus the failure mode it prevents. This is the condensed catalog for working from, and `WORKFLOW.md` keeps authority, so read the section there when a guarantee's exact wording decides a verdict. + +## D1: PR Fast-Feedback (Smoke) + +- **D1.1** Only changed targets build: each target has a paths-filter entry, unchanged targets skip. Prevents a changed target slipping through unbuilt. +- **D1.2** A validation job always runs on any PR, and a non-.NET repo replaces it (never deletes it), re-pointing every `needs:` on it, the aggregator and `smoke-build` both. Prevents a PR merging with no validation, or a dangling `needs:` failing the workflow to load. +- **D1.3** Smoke never publishes and never uploads: full compile/lint/test, no pushes, every `upload-artifact` gated `!smoke`. Prevents a PR publishing and orphaned artifacts. +- **D1.4** Workflow-file changes are not smoke-built (the filter excludes `.github/workflows/**`), actionlint still validates them. +- **D1.5** One required aggregator gates merge: `needs:` the changes and validation jobs, passes on skipped smoke, blocks on failure or cancelled, and its name is ruleset-bound (job `name:` equals ruleset `context:`, renamed together). +- **D1.6** Coverage reports to Codecov for C# and Python repos with tests, best-effort so an outage never reds the gate, with a `codecov.yml` setting statuses informational and `.gitignore` excluding coverage output. + +## D2: Validation at Entry + +- **D2.1** A dedicated entry job asserts each cross-input invariant before expensive work, downstream jobs `needs:` it. +- **D2.2** The release gate fails loud when the default branch carries a prerelease suffix or a non-default branch carries none, strips `+buildmetadata` first, and on smoke skips the check while the job still succeeds (a job-level `if:` would skip dependents with it). +- **D2.3** A dispatch publish from any ref other than `main` or `develop` fails fast. +- **D2.4** Mutually-exclusive or must-pair inputs are validated, a half-filled combination fails fast. + +## D3: Versioning and Classification + +- **D3.1** One branch per run: `github.ref` names the built branch, NBGV classifies it directly, no `IGNORE_GITHUB_REF`. +- **D3.2** Default branch yields `X.Y.Z`, every other branch `X.Y.Z-g<sha>`, and the default-branch literal in the gate, the `prerelease` expression, and `version.json`'s `publicReleaseRefSpec` all name the repo's real default branch. +- **D3.3** `version.json` sets the major.minor floor, NBGV appends git height as the patch, and both are retained even by a no-compiler repo, since they own the tag. +- **D3.4** Registry versions follow the classification per registry: NuGet.org derives prerelease from the SemVer2 suffix, PyPI builds from `AssemblyFileVersion` with `.dev0` appended on `develop` only, and the develop build stays `--pre`-selectable above the released version. +- **D3.5** A wrapper repo drives its image version from a committed `name -> version` state file, and the leaf must actually read it, since a leaf still tagging off NBGV means the wrapper is not pinned to upstream. + +## D4: Release and Publish + +- **D4.1** Gated single-branch publish: a human merge never auto-publishes, the `plan` job decides once, publishes come from a code-affecting bot push to `main`, a dispatch of `main`/`develop`, or the main-only weekly Docker schedule. +- **D4.2** `target_commitish` is the built commit's SHA (NBGV `GitCommitId`), never a branch name and never `github.sha`. +- **D4.3** Every release is a tag plus source zip, README, and LICENSE, file targets attach `release-asset-*`, and a no-file-target caller passes `expect_release_assets: false` or the release-create step fails on unmatched files. +- **D4.4** No-op republish: an unchanged version re-pushes nothing, the release-create skips when the tag exists (refreshed only on `workflow_dispatch`), registries dedupe server-side, and Docker always re-pushes by design. +- **D4.5** A failed build blocks every publish target: `github-release` needs every build, the terminal registry pusher guards `!failure() && !cancelled()`, so nothing partial ships. +- **D4.6** A deploy check asserts which release and which environment answer, waiting for convergence to a bounded timeout, with an unreachable host reported distinctly from an HTTP status. + +## D5: Resource Cleanup + +- **D5.1** A cross-job transfer artifact is deleted at its point of consumption. An in-run intermediate may rely on the retention backstop. +- **D5.2** The delete runs under the same condition as its consumer, so a no-op re-run skips the release-asset delete while the PyPI build-artifact delete still runs. +- **D5.3** Cleanup is best-effort (`continue-on-error`, tolerate a failed listing, delete all matching ids). +- **D5.4** Every `upload-artifact` sets `retention-days: 1`. +- **D5.5** Never blanket-delete the run's artifacts, which destroys diagnostics and auto-emitted build records. +- **D5.6** A durable deploy destination's retention is bounded by a declared count with one side recorded as owning the prune: the deploy where its credential can observe the destination, the host where the credential is deliberately write-only. + +## D6: Seam Conformance + +- **D6.1** The release job downloads by `pattern:`/`merge-multiple:`, never `artifact-ids:`, canonical for single-target repos too. +- **D6.2** Branch-derived config reads `inputs.branch`, never `github.ref_name`. +- **D6.3** Artifact names are branch-suffixed. +- **D6.4** A target add or drop updates the whole surface together: `enable_<target>` input, `build-<target>` job, `github-release` `needs:` entry, paths-filter entry and output, and the `smoke-build` enable-forward. + +## D7: Concurrency, Permissions, Safety + +- **D7.1** The publisher serializes: global ref-independent concurrency group, `cancel-in-progress: false`. +- **D7.2** Every reusable job declares valid `permissions:` (validated before `if:`), a callee's extra scope granted by the caller. +- **D7.3** Boolean inputs are declared in both trigger blocks and compared against both forms. +- **D7.4** Optional-dependency chaining allowlists `success`/`skipped` explicitly. + +## D8: Bots and Automation + +- **D8.1** The merge-bot enables auto-merge on `opened`/`reopened` for every Dependabot tier, dispatches squash or merge by base ref, disables on a maintainer-pushed `synchronize`, and keys concurrency on the PR number, not `github.ref`. +- **D8.2** Codegen runs a deterministic matrix over both branches, Dependabot targets both branches. +- **D8.3** The upstream tracker writes a committed `name -> version` state file via a rolling per-branch bump PR the merge-bot auto-merges, and its branch prefix must match the merge-bot's head-ref pairs or auto-merge silently never fires. +- **D8.4** An identity allowlist used as a gate emits a `::warning::` on the non-matching branch rather than falling through silently, since a renamed App slug otherwise turns the gate off invisibly. + +## D9: Style and Static + +SHA pins with version comments, the name-suffix rules, `set -Eeuo pipefail`, `if: >-`, registry-tag Docker cache with `cache-to` only the built branch on push and `cache-from` both branches, line endings per `.editorconfig`. diff --git a/.agents/skills/workflow-ci-contract/references/test-methodology.md b/.agents/skills/workflow-ci-contract/references/test-methodology.md index 3f76f114..ecd09487 100644 --- a/.agents/skills/workflow-ci-contract/references/test-methodology.md +++ b/.agents/skills/workflow-ci-contract/references/test-methodology.md @@ -1,27 +1,27 @@ -# Testing a Repo's Workflows - -The three escalating verification modes from `WORKFLOW.md` section 5, which keeps authority. N/A items (a check or scenario for an absent construct) are recorded and excluded, never failed. - -## 5A: Static Audit - -Read the workflow files plus `version.json` and assert the structural fact behind each applicable D-guarantee, each pass, fail, or N/A with a `file:line` citation, asserting each input in the layer that declares it. The core sweep covers: the paths-filter's target coverage and `.github/workflows/**` exclusion, smoke gating on every upload, the aggregator's `needs:` and skip/fail handling, the entry validation jobs and the two-directional release gate, the single-branch NBGV classification and the three default-branch literals agreeing, `target_commitish` from `GitCommitId`, the consume-then-delete artifact lifecycle with `retention-days: 1` everywhere and no blanket delete, the `pattern:` handoff and `inputs.branch` config, the publisher's serialized concurrency, and the SHA pins. `WORKFLOW.md` 5A lists the per-type addenda (console runtime matrix, NuGet `--skip-duplicate`, the PyPI OIDC environment split, Docker `expect_release_assets` and cache shape, the static-site deploy gates), so apply only the ones the repo's types imply. - -## 5B: Trace Scenarios - -For each applicable scenario, evaluate every job's `if:`/`needs:` against the inputs and compare the predicted run/skip, version, release, and artifact end state to the expected table in `WORKFLOW.md` 5B. The load-bearing ones: - -- **S1** a PR touching a target: that target smoke-builds, nothing uploads, the aggregator succeeds. -- **S5/S6** a bot push to `main`: publishes only when code-affecting, and a human push never does. -- **S7** a publish run builds the one trigger branch with the right classification and leaves no dangling artifacts. -- **S8** a dispatch from a ref other than `main`/`develop` fails fast. -- **S9** a no-op re-run: release-create skipped, registries dedupe, PyPI build artifact still deleted, Docker still re-pushes. -- **S10** branch and version classification disagree: the gate fails loud and everything downstream skips. -- **S12/S13** a deploy dispatch: ref gate first, environment re-asserted, pointer flip separate, live check names the release, and a production deploy from a non-default ref fails before anything is written. - -## 5C: Live Probe - -Only for what a static trace cannot settle: a trivial PR to confirm S1, a smoke push-probe of both branches' version classification, registry queries after a real publish, and the artifact lifecycle read from a real run's logs. The deploy ref gate is verified only by tripping it, and that dispatch is the maintainer's to run: the agent prepares the command and reads back the four evidence items (gate conclusion, its error text, every downstream job skipped, deployment count unchanged), and a harness refusal to fire it is the control working, never something to re-shape. - -## Verdict - -Operational iff every applicable 5A item passes and every applicable 5B scenario matches, with the failing guarantees and their triggering inputs named, and the N/A list recorded. Per-project-type walkthroughs mapping scenarios onto targets, including source-only, static-site, and operational shapes, are `WORKFLOW.md` section 6. +# Testing a Repo's Workflows + +The three escalating verification modes from `WORKFLOW.md` section 5, which keeps authority. N/A items (a check or scenario for an absent construct) are recorded and excluded, never failed. + +## 5A: Static Audit + +Read the workflow files plus `version.json` and assert the structural fact behind each applicable D-guarantee, each pass, fail, or N/A with a `file:line` citation, asserting each input in the layer that declares it. The core sweep covers: the paths-filter's target coverage and `.github/workflows/**` exclusion, smoke gating on every upload, the aggregator's `needs:` and skip/fail handling, the entry validation jobs and the two-directional release gate, the single-branch NBGV classification and the three default-branch literals agreeing, `target_commitish` from `GitCommitId`, the consume-then-delete artifact lifecycle with `retention-days: 1` everywhere and no blanket delete, the `pattern:` handoff and `inputs.branch` config, the publisher's serialized concurrency, and the SHA pins. `WORKFLOW.md` 5A lists the per-type addenda (console runtime matrix, NuGet `--skip-duplicate`, the PyPI OIDC environment split, Docker `expect_release_assets` and cache shape, the static-site deploy gates), so apply only the ones the repo's types imply. + +## 5B: Trace Scenarios + +For each applicable scenario, evaluate every job's `if:`/`needs:` against the inputs and compare the predicted run/skip, version, release, and artifact end state to the expected table in `WORKFLOW.md` 5B. The load-bearing ones: + +- **S1** a PR touching a target: that target smoke-builds, nothing uploads, the aggregator succeeds. +- **S5/S6** a bot push to `main`: publishes only when code-affecting, and a human push never does. +- **S7** a publish run builds the one trigger branch with the right classification and leaves no dangling artifacts. +- **S8** a dispatch from a ref other than `main`/`develop` fails fast. +- **S9** a no-op re-run: release-create skipped, registries dedupe, PyPI build artifact still deleted, Docker still re-pushes. +- **S10** branch and version classification disagree: the gate fails loud and everything downstream skips. +- **S12/S13** a deploy dispatch: ref gate first, environment re-asserted, pointer flip separate, live check names the release, and a production deploy from a non-default ref fails before anything is written. + +## 5C: Live Probe + +Only for what a static trace cannot settle: a trivial PR to confirm S1, a smoke push-probe of both branches' version classification, registry queries after a real publish, and the artifact lifecycle read from a real run's logs. The deploy ref gate is verified only by tripping it, and that dispatch is the maintainer's to run: the agent prepares the command and reads back the four evidence items (gate conclusion, its error text, every downstream job skipped, deployment count unchanged), and a harness refusal to fire it is the control working, never something to re-shape. + +## Verdict + +Operational iff every applicable 5A item passes and every applicable 5B scenario matches, with the failing guarantees and their triggering inputs named, and the N/A list recorded. Per-project-type walkthroughs mapping scenarios onto targets, including source-only, static-site, and operational shapes, are `WORKFLOW.md` section 6. diff --git a/.claude-plugin/fleet-skills/.claude-plugin/plugin.json b/.claude-plugin/fleet-skills/.claude-plugin/plugin.json index 00016e2f..0838d530 100644 --- a/.claude-plugin/fleet-skills/.claude-plugin/plugin.json +++ b/.claude-plugin/fleet-skills/.claude-plugin/plugin.json @@ -1,28 +1,28 @@ -{ - "name": "fleet-skills", - "version": "0.0.0", - "description": "Fleet-wide agent rules and per-language conventions packaged as Claude Code Skills, generated from .agents/skills/.", - "author": { - "name": "ptr727" - }, - "skills": [ - "./skills/agent-conduct", - "./skills/audit-a-repo", - "./skills/carried-instruction-file-guard", - "./skills/comment-and-doc-style", - "./skills/copilot-instructions-keeper", - "./skills/dotnet-codestyle", - "./skills/fleet-conformance-check", - "./skills/git-commit-conventions", - "./skills/operational-vs-release-workflow", - "./skills/pr-review-conduct", - "./skills/python-codestyle", - "./skills/repo-worktree", - "./skills/resync-a-repo", - "./skills/shell-codestyle", - "./skills/skill-lifecycle", - "./skills/standup-a-repo", - "./skills/upstream-contribution-workflow", - "./skills/workflow-ci-contract" - ] -} +{ + "name": "fleet-skills", + "version": "0.0.0", + "description": "Fleet-wide agent rules and per-language conventions packaged as Claude Code Skills, generated from .agents/skills/.", + "author": { + "name": "ptr727" + }, + "skills": [ + "./skills/agent-conduct", + "./skills/audit-a-repo", + "./skills/carried-instruction-file-guard", + "./skills/comment-and-doc-style", + "./skills/copilot-instructions-keeper", + "./skills/dotnet-codestyle", + "./skills/fleet-conformance-check", + "./skills/git-commit-conventions", + "./skills/operational-vs-release-workflow", + "./skills/pr-review-conduct", + "./skills/python-codestyle", + "./skills/repo-worktree", + "./skills/resync-a-repo", + "./skills/shell-codestyle", + "./skills/skill-lifecycle", + "./skills/standup-a-repo", + "./skills/upstream-contribution-workflow", + "./skills/workflow-ci-contract" + ] +} diff --git a/.claude-plugin/fleet-skills/.source-digest b/.claude-plugin/fleet-skills/.source-digest index 0f5c47be..0de5efcc 100644 --- a/.claude-plugin/fleet-skills/.source-digest +++ b/.claude-plugin/fleet-skills/.source-digest @@ -1 +1 @@ -c69f21b41228327e +c9985935f7a2c23f diff --git a/.claude-plugin/fleet-skills/skills/agent-conduct/SKILL.md b/.claude-plugin/fleet-skills/skills/agent-conduct/SKILL.md index a1088902..7d189ca9 100644 --- a/.claude-plugin/fleet-skills/skills/agent-conduct/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/agent-conduct/SKILL.md @@ -1,44 +1,44 @@ ---- -name: agent-conduct -description: >- - Surfaces the ptr727/ProjectTemplate fleet's conduct rules at the three decision moments they are violated: about to claim work is done, verified, green, or fixed, about to proceed on an assumption the user could cheaply confirm, and a failure or review finding just surfaced a durable lesson. Use this whenever about to report success or completion of any task, whenever about to pick a default, guess an intent, or resolve an ambiguity without asking, whenever work is blocked on a decision or authorization only the user can give, and whenever an incident, a wrong answer, or a repeated correction just taught something a future session must honor. Deliberately narrow: the carried AGENTS.md sections are the always-on layer, and this skill fires at the moments rather than duplicating them, so do not load it as general background. Where a sibling skill owns the moment, it wins: git-commit-conventions for committing, pr-review-conduct for review and merge claims, comment-and-doc-style for prose. The GOVERNANCE.md sections this skill summarizes keep the full rules. ---- - -# Agent Conduct - -## Why This Exists - -The fleet's conduct rules (verification before claiming done, asking instead of assuming, recording lessons) lived only in doc sections nothing surfaced at the moment of violation, so they were honored by whoever happened to have read them recently. This skill is the decision-moment surface. The full rules stay in `GOVERNANCE.md` ("Verification Discipline", "Communicating with the User", "Durable Knowledge and Self-Improvement"), which keeps authority, and in the carried `AGENTS.md` "Context and Delegation Discipline" section, which is the always-on layer. - -## Before Claiming Done - -Read `GOVERNANCE.md` "Verification Discipline" before reporting success on anything non-trivial. Its unifying property: every failure it lists is green. The checks that bind here: - -- **A green check is not evidence the work happened.** A skipped job and a passing job are indistinguishable in an aggregated required check, so confirm from the log that the job ran and produced what it promises. -- **Locate every check the change owes before running any**, from what the repository declares (`OPERATIONS.md` "Local Verification" beside the workflows), not from what the pipeline happens to run, since part of a contract is routinely unreachable from a runner and green is then the precise signal it was skipped. -- **Run the repo's whole lint gate before every push**, not the parts that look relevant, because the tool most likely to catch a change is often the one it seems least about. -- **A launched process is not a result.** Report the output the wait produced, and where it produced none, that absence is the report. Never name an external cause the record does not carry. -- **A local clone is not the branch it names.** Fetch immediately before reading, or read the live ref, and name the ref and commit in any finding a local read produced. -- **A "does not exist" claim names the branch it was checked against.** A worktree's default branch is not necessarily the one the content lives on: in-flight content on a `release`-model repo lands on `develop` before `main`, per `GOVERNANCE.md` "Branching Model," so check that branch before reporting anything absent repo-wide. -- **A test asserts the mechanism it names, and a gate has to be watched failing.** A case that passes for an incidental reason is worse than no case, because it is later cited as evidence. -- **Platform-specific code is verified only on the platform it runs on.** Reasoning about PowerShell, macOS, or WSL-specific behavior from a different host is not verification, however closely it matches an already-tested equivalent elsewhere. State an untested structural match as exactly that, never in the words used for a tested fact, and when no agent in the loop has access to the target platform, say so and defer or ship it labeled unverified. - -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`. - -## Before Assuming - -- **Ask when the user can cheaply confirm.** An assumption that saves one question and is wrong costs the rework plus the trust, so a genuine ambiguity in intent, scope, or authorization is raised, not resolved by picking the likelier reading. Rules that already answer the question (the committed instruction set) are not ambiguity, so read them first rather than asking what they state. -- **Raise blocked work as a direct interactive prompt** at the point the work stops, per `GOVERNANCE.md` "Communicating with the User": the blocked item is the message, the options offered are the actions themselves, and a handoff buried in a summary paragraph is a handoff that did not happen. Numbered lists are the fallback where no prompt mechanism exists. -- **References are clickable where they are read**: a pull request, issue, or commit on a Markdown surface is a Markdown link, and on a surface that renders neither, a bare `#123` with the link in the message before the prompt. -- **Capability is not permission.** A token's reach, a tool that happens to work, or a similar grant in a past session authorizes nothing, and the irreversible step (merge, publish, release, delete) stays the maintainer's. - -## When a Failure Surfaces a Lesson - -- **Durable knowledge lands in the committed docs, not in agent memory**, as part of the change that surfaced it, per `GOVERNANCE.md` "Durable Knowledge and Self-Improvement". Memory does not survive a new session or machine, so it holds only environment nuance and in-flight state. -- **Where the governing doc is carried from the hub, propose the fix upstream** rather than only patching it locally, since a local fix leaves every sibling repo with the same trap. -- **A review flags an instance, so fix the class**: sweep for the siblings before replying, because reviewers sample rather than enumerate. -- **A rule that keeps needing restating** is usually a stale or missing skills install, so run `python3 scripts/skills_install.py --report` from a hub checkout (the `fleet-conformance-check` skill) before concluding the rule does not exist. - -## Delegation, in One Paragraph - -The always-on rules live in `AGENTS.md` "Context and Delegation Discipline" and are not restated here. The two that intersect conduct: brief a subagent so it never needs a governance file, since anything it must honor has to be in its prompt, and never tier down the seat holding the judgment, because governance wording and the decision to decline a review finding are fleet-wide and durable when wrong. +--- +name: agent-conduct +description: >- + Surfaces the ptr727/ProjectTemplate fleet's conduct rules at the three decision moments they are violated: about to claim work is done, verified, green, or fixed, about to proceed on an assumption the user could cheaply confirm, and a failure or review finding just surfaced a durable lesson. Use this whenever about to report success or completion of any task, whenever about to pick a default, guess an intent, or resolve an ambiguity without asking, whenever work is blocked on a decision or authorization only the user can give, and whenever an incident, a wrong answer, or a repeated correction just taught something a future session must honor. Deliberately narrow: the carried AGENTS.md sections are the always-on layer, and this skill fires at the moments rather than duplicating them, so do not load it as general background. Where a sibling skill owns the moment, it wins: git-commit-conventions for committing, pr-review-conduct for review and merge claims, comment-and-doc-style for prose. The GOVERNANCE.md sections this skill summarizes keep the full rules. +--- + +# Agent Conduct + +## Why This Exists + +The fleet's conduct rules (verification before claiming done, asking instead of assuming, recording lessons) lived only in doc sections nothing surfaced at the moment of violation, so they were honored by whoever happened to have read them recently. This skill is the decision-moment surface. The full rules stay in `GOVERNANCE.md` ("Verification Discipline", "Communicating with the User", "Durable Knowledge and Self-Improvement"), which keeps authority, and in the carried `AGENTS.md` "Context and Delegation Discipline" section, which is the always-on layer. + +## Before Claiming Done + +Read `GOVERNANCE.md` "Verification Discipline" before reporting success on anything non-trivial. Its unifying property: every failure it lists is green. The checks that bind here: + +- **A green check is not evidence the work happened.** A skipped job and a passing job are indistinguishable in an aggregated required check, so confirm from the log that the job ran and produced what it promises. +- **Locate every check the change owes before running any**, from what the repository declares (`OPERATIONS.md` "Local Verification" beside the workflows), not from what the pipeline happens to run, since part of a contract is routinely unreachable from a runner and green is then the precise signal it was skipped. +- **Run the repo's whole lint gate before every push**, not the parts that look relevant, because the tool most likely to catch a change is often the one it seems least about. +- **A launched process is not a result.** Report the output the wait produced, and where it produced none, that absence is the report. Never name an external cause the record does not carry. +- **A local clone is not the branch it names.** Fetch immediately before reading, or read the live ref, and name the ref and commit in any finding a local read produced. +- **A "does not exist" claim names the branch it was checked against.** A worktree's default branch is not necessarily the one the content lives on: in-flight content on a `release`-model repo lands on `develop` before `main`, per `GOVERNANCE.md` "Branching Model," so check that branch before reporting anything absent repo-wide. +- **A test asserts the mechanism it names, and a gate has to be watched failing.** A case that passes for an incidental reason is worse than no case, because it is later cited as evidence. +- **Platform-specific code is verified only on the platform it runs on.** Reasoning about PowerShell, macOS, or WSL-specific behavior from a different host is not verification, however closely it matches an already-tested equivalent elsewhere. State an untested structural match as exactly that, never in the words used for a tested fact, and when no agent in the loop has access to the target platform, say so and defer or ship it labeled unverified. + +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`. + +## Before Assuming + +- **Ask when the user can cheaply confirm.** An assumption that saves one question and is wrong costs the rework plus the trust, so a genuine ambiguity in intent, scope, or authorization is raised, not resolved by picking the likelier reading. Rules that already answer the question (the committed instruction set) are not ambiguity, so read them first rather than asking what they state. +- **Raise blocked work as a direct interactive prompt** at the point the work stops, per `GOVERNANCE.md` "Communicating with the User": the blocked item is the message, the options offered are the actions themselves, and a handoff buried in a summary paragraph is a handoff that did not happen. Numbered lists are the fallback where no prompt mechanism exists. +- **References are clickable where they are read**: a pull request, issue, or commit on a Markdown surface is a Markdown link, and on a surface that renders neither, a bare `#123` with the link in the message before the prompt. +- **Capability is not permission.** A token's reach, a tool that happens to work, or a similar grant in a past session authorizes nothing, and the irreversible step (merge, publish, release, delete) stays the maintainer's. + +## When a Failure Surfaces a Lesson + +- **Durable knowledge lands in the committed docs, not in agent memory**, as part of the change that surfaced it, per `GOVERNANCE.md` "Durable Knowledge and Self-Improvement". Memory does not survive a new session or machine, so it holds only environment nuance and in-flight state. +- **Where the governing doc is carried from the hub, propose the fix upstream** rather than only patching it locally, since a local fix leaves every sibling repo with the same trap. +- **A review flags an instance, so fix the class**: sweep for the siblings before replying, because reviewers sample rather than enumerate. +- **A rule that keeps needing restating** is usually a stale or missing skills install, so run `python3 scripts/skills_install.py --report` from a hub checkout (the `fleet-conformance-check` skill) before concluding the rule does not exist. + +## Delegation, in One Paragraph + +The always-on rules live in `AGENTS.md` "Context and Delegation Discipline" and are not restated here. The two that intersect conduct: brief a subagent so it never needs a governance file, since anything it must honor has to be in its prompt, and never tier down the seat holding the judgment, because governance wording and the decision to decline a review finding are fleet-wide and durable when wrong. diff --git a/.claude-plugin/fleet-skills/skills/audit-a-repo/SKILL.md b/.claude-plugin/fleet-skills/skills/audit-a-repo/SKILL.md index 10609633..01ee0b2b 100644 --- a/.claude-plugin/fleet-skills/skills/audit-a-repo/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/audit-a-repo/SKILL.md @@ -1,37 +1,37 @@ ---- -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. ---- - -# Audit a Repo - -## Why This Exists - -The audit is the fleet's measurement procedure, and the two failure shapes it guards against are both silent: a repo judged conformant with no committed evidence, and an audit that quietly edits what it was supposed to measure. `AUDIT.md` in the hub is the procedure and keeps authority. This skill carries the rules that get skipped in practice and says which section owns each step. - -## Before Measuring Anything - -- **Route first.** A repo with no carried instruction set, or a partial one, has a baseline that never arrived rather than drift to report, so it goes to `STANDUP.md` sections 1A and 2 first (`AUDIT.md` section 0). Auditing it anyway produces a report that is all absences and reads as catastrophe. -- **Verify the host.** Run `python3 scripts/host_gate.py --repo <target-checkout>` from the hub checkout before any hub tool, and pass `--repo`, since a bare run skips the target's own `host-tools.json` overlay. A stale tool answers `--version`, looks healthy, and produces a wrong answer. -- **Read `main` as ground truth**, for both workflow models, and read `develop` only to detect divergence (`AUDIT.md` section 1). An `operational` repo's `develop` is mid-flight by design, so conformance work sitting there is un-promoted work, not a defect, and it counts when it reaches `main`. Use `spec/audit.py --branch <ref>` to preview in-flight work, which stamps the override so the finding cannot be mistaken for one against ground truth. - -## Measuring - -- **Resolve the repo's types from `registry/repos.json`** and classify a `classificationPending` entry from the tree (`AUDIT.md` section 2). The applicability gate is `WORKFLOW.md` section 1: a check governing an absent construct is N/A, excluded from the verdict, and never a defect (`AUDIT.md` section 3). -- **Know what the runner does and does not prove.** `spec/audit.py` mechanizes the deterministic subset only: settings, rulesets, secret names, file and section presence, verbatim hashing, interface wiring, Dependabot coverage, branch facts. It evaluates no check under a type in `spec/project-types.json`, so every per-type check is judged by hand, and a clean run is no evidence for them (`AUDIT.md` section 4). Silence from a tool that was never looking reads exactly like a pass. -- **Judge letter and intent per check** and keep the vocabulary: letter miss with intent satisfied is a drift finding, both missing is a defect, and operational is binary over the applicable set (`AUDIT.md` sections 4 and 7). Do not invent a parallel scheme. -- **Assert the Actions implement `WORKFLOW.md`** by outcome, not by matching catalog snippets byte for byte: the 5A static audit with a `file:line` citation per applicable guarantee, then the 5B trace scenarios (`AUDIT.md` section 5). The `workflow-ci-contract` skill summarizes that contract. -- **Diff live settings, rulesets, and secrets** against `repo-config/` with the normalized comparisons `AUDIT.md` section 6 gives, using its commands rather than hand-rolled ones, since the normalization details (rule sorting, `bypass_actors` deliberately excluded, the operational develop payload) are where hand-rolled diffs go wrong. - -## Reporting - -- **Write `reports/<repo>/audit.md` from `reports/_template.md`**, findings ranked most severe first, each with the `file:line` it was judged against, and quote the run stamp, since findings are a point-in-time snapshot (`AUDIT.md` section 8). -- **The hub authors the report.** A downstream repo never opens a hub pull request to write its own, which would be self-certification. Downstream context goes into issues filed against the hub instead. -- **Generate a convergence issue, never compose one**: `spec/audit.py --issue <repo>` emits it from live findings. An agent picking such an issue up re-runs the audit first and acts on the live result, not the pasted findings. -- **Reconcile registry `driftNotes` in the same pass**: a resolved deviation's note is deleted, not left describing finished work, and a note naming a check id is retired by a person, not by a run (`AUDIT.md` section 8). -- **Stale-versus-modified classification needs a full hub clone with git history.** Without one, compare against the current hub canonical on `main`, which decides current-match only. - -## After the Report - -Measuring and fixing are separate phases. Converging is `AUDIT.md` section 10: fixes ship as pull requests on the target repo, one focused pull request per drift class, the Copilot loop driven to green per the `pr-review-conduct` skill, and the maintainer merges. For a repo already stood up, `RESYNC.md` sequences the findings, since order matters (a deletion lands before the re-vendor that would refresh it). Systemic drift shared by many repos is fixed in the hub spec, not hand-patched per repo, and spec questions are escalated rather than resolved silently (`AUDIT.md` section 9). +--- +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. +--- + +# Audit a Repo + +## Why This Exists + +The audit is the fleet's measurement procedure, and the two failure shapes it guards against are both silent: a repo judged conformant with no committed evidence, and an audit that quietly edits what it was supposed to measure. `AUDIT.md` in the hub is the procedure and keeps authority. This skill carries the rules that get skipped in practice and says which section owns each step. + +## Before Measuring Anything + +- **Route first.** A repo with no carried instruction set, or a partial one, has a baseline that never arrived rather than drift to report, so it goes to `STANDUP.md` sections 1A and 2 first (`AUDIT.md` section 0). Auditing it anyway produces a report that is all absences and reads as catastrophe. +- **Verify the host.** Run `python3 scripts/host_gate.py --repo <target-checkout>` from the hub checkout before any hub tool, and pass `--repo`, since a bare run skips the target's own `host-tools.json` overlay. A stale tool answers `--version`, looks healthy, and produces a wrong answer. +- **Read `main` as ground truth**, for both workflow models, and read `develop` only to detect divergence (`AUDIT.md` section 1). An `operational` repo's `develop` is mid-flight by design, so conformance work sitting there is un-promoted work, not a defect, and it counts when it reaches `main`. Use `spec/audit.py --branch <ref>` to preview in-flight work, which stamps the override so the finding cannot be mistaken for one against ground truth. + +## Measuring + +- **Resolve the repo's types from `registry/repos.json`** and classify a `classificationPending` entry from the tree (`AUDIT.md` section 2). The applicability gate is `WORKFLOW.md` section 1: a check governing an absent construct is N/A, excluded from the verdict, and never a defect (`AUDIT.md` section 3). +- **Know what the runner does and does not prove.** `spec/audit.py` mechanizes the deterministic subset only: settings, rulesets, secret names, file and section presence, verbatim hashing, interface wiring, Dependabot coverage, branch facts. It evaluates no check under a type in `spec/project-types.json`, so every per-type check is judged by hand, and a clean run is no evidence for them (`AUDIT.md` section 4). Silence from a tool that was never looking reads exactly like a pass. +- **Judge letter and intent per check** and keep the vocabulary: letter miss with intent satisfied is a drift finding, both missing is a defect, and operational is binary over the applicable set (`AUDIT.md` sections 4 and 7). Do not invent a parallel scheme. +- **Assert the Actions implement `WORKFLOW.md`** by outcome, not by matching catalog snippets byte for byte: the 5A static audit with a `file:line` citation per applicable guarantee, then the 5B trace scenarios (`AUDIT.md` section 5). The `workflow-ci-contract` skill summarizes that contract. +- **Diff live settings, rulesets, and secrets** against `repo-config/` with the normalized comparisons `AUDIT.md` section 6 gives, using its commands rather than hand-rolled ones, since the normalization details (rule sorting, `bypass_actors` deliberately excluded, the operational develop payload) are where hand-rolled diffs go wrong. + +## Reporting + +- **Write `reports/<repo>/audit.md` from `reports/_template.md`**, findings ranked most severe first, each with the `file:line` it was judged against, and quote the run stamp, since findings are a point-in-time snapshot (`AUDIT.md` section 8). +- **The hub authors the report.** A downstream repo never opens a hub pull request to write its own, which would be self-certification. Downstream context goes into issues filed against the hub instead. +- **Generate a convergence issue, never compose one**: `spec/audit.py --issue <repo>` emits it from live findings. An agent picking such an issue up re-runs the audit first and acts on the live result, not the pasted findings. +- **Reconcile registry `driftNotes` in the same pass**: a resolved deviation's note is deleted, not left describing finished work, and a note naming a check id is retired by a person, not by a run (`AUDIT.md` section 8). +- **Stale-versus-modified classification needs a full hub clone with git history.** Without one, compare against the current hub canonical on `main`, which decides current-match only. + +## After the Report + +Measuring and fixing are separate phases. Converging is `AUDIT.md` section 10: fixes ship as pull requests on the target repo, one focused pull request per drift class, the Copilot loop driven to green per the `pr-review-conduct` skill, and the maintainer merges. For a repo already stood up, `RESYNC.md` sequences the findings, since order matters (a deletion lands before the re-vendor that would refresh it). Systemic drift shared by many repos is fixed in the hub spec, not hand-patched per repo, and spec questions are escalated rather than resolved silently (`AUDIT.md` section 9). diff --git a/.claude-plugin/fleet-skills/skills/carried-instruction-file-guard/SKILL.md b/.claude-plugin/fleet-skills/skills/carried-instruction-file-guard/SKILL.md index 012fc4e8..47f88033 100644 --- a/.claude-plugin/fleet-skills/skills/carried-instruction-file-guard/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/carried-instruction-file-guard/SKILL.md @@ -1,31 +1,31 @@ ---- -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. ---- - -# Carried Instruction File Guard - -## Why this exists - -A downstream repo's `AGENTS.md`/`GOVERNANCE.md`/`CODESTYLE.md`/`WORKFLOW.md` can hold two different kinds of content mixed in one file: sections that are stale copies of the hub's fleet-wide rules, and local rules the repo wrote for a fault the fleet has never seen elsewhere. Re-vendoring the hub's canonical version over the whole file deletes the second kind silently, because nothing about the diff looks wrong. This has actually happened: a resync replaced a repo's `AGENTS.md` wholesale with the hub's, and the repo's own local additions were gone with no error, no warning, and no review comment calling it out. - -The fix is not "be careful." Being careful is what failed the first time. The fix is a mechanical check you run before any overwrite touches one of these four files, every time, regardless of how routine the request sounds. - -## Before you touch any of these four files - -1. **Check whether the file's content is declared `verbatim` or `intent`.** The hub's `spec/section-model.md` (fetch it from a hub checkout, `github.com/ptr727/ProjectTemplate`, if you don't have one) names, section by section, which parts of `AGENTS.md` and `GOVERNANCE.md` are universal fleet law (safe to byte-match against the hub) and which describe the repo itself (never safe to overwrite from another repo). `CODESTYLE.md` and `WORKFLOW.md` are carried whole at `intent` fidelity, judged by meaning, not hashed. -2. **If any part of the file is `intent`, or if the file predates a clean split into hub-governed sections, do not diff-and-replace. Probe instead.** For each rule or paragraph in the current file that is not obviously boilerplate: - - Pick the phrase in it that is most peculiar to this repo, not generic governance vocabulary. A rule about "always sign commits" is generic. A rule about "this repo's Docker image pins Alpine 3.19 because 3.20 broke the s6 supervisor" is peculiar. - - Grep the hub's canonical copy of the same file for that peculiar phrase. - - **Absent from the hub canonical means it is a local addition.** It is never dropped because it looks similar to something else, and never dropped because a merge or overwrite would be simpler without it. -3. **A local addition found by the probe gets a destination, not a deletion.** Either it names a rule that should apply fleet-wide (flag it for the maintainer to promote into the hub), or it is genuinely specific to this repo and moves to the repo's own topical doc before the carried file is touched: `CODESTYLE.md` for a language/formatting convention, `ARCHITECTURE.md` for a design decision, `OPERATIONS.md` for a runbook or operational note, `TODO.md` for backlog. Move it, confirm it is not lost, and only then proceed with the carry. -4. **Do not trust a similarity or word-overlap check for step 2.** A repo-specific rule written in ordinary governance language reads as a reworded duplicate of an unrelated hub rule to that kind of check, and it will confidently tell you the local content is redundant when it is not. Exact phrase presence or absence is the only check that has held up. - -## What is actually safe to overwrite without this procedure - -A section `spec/section-model.md` names as `verbatim`, in a file that is already cleanly split (the file carries only that declared section, nothing else mixed in), can be re-vendored directly: byte-matching it against the hub canonical is the point of `verbatim` fidelity, and the audit already checks it that way. The guard above is for everything else: `intent`-fidelity content, a file that has not been split yet, or any file you are not certain is clean. - -## If you are not sure which case you are in - -Stop and say so, rather than guessing. Naming the uncertainty costs one sentence. Silently overwriting the wrong thing costs someone's local rules with no way to notice until much later. +--- +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. +--- + +# Carried Instruction File Guard + +## Why this exists + +A downstream repo's `AGENTS.md`/`GOVERNANCE.md`/`CODESTYLE.md`/`WORKFLOW.md` can hold two different kinds of content mixed in one file: sections that are stale copies of the hub's fleet-wide rules, and local rules the repo wrote for a fault the fleet has never seen elsewhere. Re-vendoring the hub's canonical version over the whole file deletes the second kind silently, because nothing about the diff looks wrong. This has actually happened: a resync replaced a repo's `AGENTS.md` wholesale with the hub's, and the repo's own local additions were gone with no error, no warning, and no review comment calling it out. + +The fix is not "be careful." Being careful is what failed the first time. The fix is a mechanical check you run before any overwrite touches one of these four files, every time, regardless of how routine the request sounds. + +## Before you touch any of these four files + +1. **Check whether the file's content is declared `verbatim` or `intent`.** The hub's `spec/section-model.md` (fetch it from a hub checkout, `github.com/ptr727/ProjectTemplate`, if you don't have one) names, section by section, which parts of `AGENTS.md` and `GOVERNANCE.md` are universal fleet law (safe to byte-match against the hub) and which describe the repo itself (never safe to overwrite from another repo). `CODESTYLE.md` and `WORKFLOW.md` are carried whole at `intent` fidelity, judged by meaning, not hashed. +2. **If any part of the file is `intent`, or if the file predates a clean split into hub-governed sections, do not diff-and-replace. Probe instead.** For each rule or paragraph in the current file that is not obviously boilerplate: + - Pick the phrase in it that is most peculiar to this repo, not generic governance vocabulary. A rule about "always sign commits" is generic. A rule about "this repo's Docker image pins Alpine 3.19 because 3.20 broke the s6 supervisor" is peculiar. + - Grep the hub's canonical copy of the same file for that peculiar phrase. + - **Absent from the hub canonical means it is a local addition.** It is never dropped because it looks similar to something else, and never dropped because a merge or overwrite would be simpler without it. +3. **A local addition found by the probe gets a destination, not a deletion.** Either it names a rule that should apply fleet-wide (flag it for the maintainer to promote into the hub), or it is genuinely specific to this repo and moves to the repo's own topical doc before the carried file is touched: `CODESTYLE.md` for a language/formatting convention, `ARCHITECTURE.md` for a design decision, `OPERATIONS.md` for a runbook or operational note, `TODO.md` for backlog. Move it, confirm it is not lost, and only then proceed with the carry. +4. **Do not trust a similarity or word-overlap check for step 2.** A repo-specific rule written in ordinary governance language reads as a reworded duplicate of an unrelated hub rule to that kind of check, and it will confidently tell you the local content is redundant when it is not. Exact phrase presence or absence is the only check that has held up. + +## What is actually safe to overwrite without this procedure + +A section `spec/section-model.md` names as `verbatim`, in a file that is already cleanly split (the file carries only that declared section, nothing else mixed in), can be re-vendored directly: byte-matching it against the hub canonical is the point of `verbatim` fidelity, and the audit already checks it that way. The guard above is for everything else: `intent`-fidelity content, a file that has not been split yet, or any file you are not certain is clean. + +## If you are not sure which case you are in + +Stop and say so, rather than guessing. Naming the uncertainty costs one sentence. Silently overwriting the wrong thing costs someone's local rules with no way to notice until much later. diff --git a/.claude-plugin/fleet-skills/skills/comment-and-doc-style/SKILL.md b/.claude-plugin/fleet-skills/skills/comment-and-doc-style/SKILL.md index e383d705..dc834f25 100644 --- a/.claude-plugin/fleet-skills/skills/comment-and-doc-style/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/comment-and-doc-style/SKILL.md @@ -1,231 +1,231 @@ ---- -name: comment-and-doc-style -description: >- - Governs prose, comment, Markdown, character-set, line-ending, and PR-title/commit-message - conventions for every ptr727/ProjectTemplate fleet repo. Use this whenever writing or editing a - code comment, workflow comment, Markdown doc, commit message, or PR title, whenever choosing - which characters to type in agent-authored text, whenever the file being edited is CRLF, and - whenever naming a tool in prose or docs. Triggers even when the task looks purely mechanical, - such as "just fix a typo" or "add a one-line comment", because the fleet's ASCII character-set - tiers, no-semicolon rule, comment-growth discipline, and CRLF-preservation rule are each easy to - violate without noticing: an em dash slipped into a sentence, a comment that grew by one more - clause, or a text-mode edit that silently flattens a CRLF file to LF. Also triggers when - authoring a new Markdown file (reference-style links, Table of Contents, present tense), when a - carried instruction file (AGENTS.md, GOVERNANCE.md, CODESTYLE.md, WORKFLOW.md, - .github/copilot-instructions.md) is being edited (no coordination references to the template or - a sibling repo), and when writing a PR title or commit message (imperative subject, no vague - titles, no unsolicited Co-Authored-By, no release-bump magnitude). ---- - -# Comment and Doc Style - -## Why this exists - -These are the fleet's mechanical prose rules, kept in one place instead of re-derived per repo or -per session: how to write a comment, which characters an agent may type, how a Markdown file is -structured, how a carried instruction file may reference the hub, and how a PR title or commit -message reads. None of these are matters of taste. Each is checked, by `prose_lint.py`, -`editorconfig-checker`, `markdownlint`, `cspell`, or a human reviewer, and each has been the exact -subject of a real review finding. - -## Naming tools in prose - -Use each tool's official casing in task labels, docs, and prose: `.NET` (not `.Net`), -`CSharpier`, `ruff`, `pyright`, `uv`. Do not invent personal variants. - -## Markdown files: linting and spelling - -- **Markdown lints clean, repo-wide.** Every `.md` file is error and warning free via - `markdownlint-cli2` against the shared `.markdownlint-cli2.jsonc`. A rule it deliberately - 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. -- **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. -- **CI's spelling gate covers `README.md` and `HISTORY.md` only**, deliberately not every `.md` - file, so a new topical doc is not spell-gated in CI (the editor extension still flags it live). - A repo may widen its own CI list, README plus HISTORY is the default. A repo shipping no - `HISTORY.md` drops it from the CI workflow, the `Lint: Spelling` task, and the GOVERNANCE.md - cspell line together, all three or none. -- **`HISTORY.md` mirrors the README's opening**: the same `# <Title>`, the same tagline verbatim - (the first line after the README's H1), then its own `## Release History`. It never repeats a - paragraph below the README's tagline. -- **"Markdown" is a proper noun in prose** (a Markdown file, a Markdown-only repo), lowercase only - for what a machine reads: a tool or package name (`markdownlint`), a settings key, a heading - anchor, a file extension. - -## Markdown formatting - -- **Reference-style links everywhere**, except the four files read one section at a time rather - than end to end: `AGENTS.md`, `GOVERNANCE.md`, `OPERATIONS.md`, `.github/copilot-instructions.md`. - Those keep inline links so a target resolves where it is read. Every other Markdown file defines - every URI at the bottom, grouped by type under an HTML-comment header, each group alphabetized - by reference name rather than by the full definition line (a name that is a prefix of another - sorts first, `[governance]` above `[governance-branching-model]`). A URL inside a fenced code - block stays inline. See `references/markdown-links.md` for the full grouping and naming - convention. -- **Table of Contents**: generated by the Markdown All in One extension on save, never - hand-authored or hand-edited. Exclude a heading with an inline `<!-- omit from toc -->` marker. -- **One logical paragraph per line**, no hard-wrap line-length limit. For an intentional line - break within a block (stacked badges, status lines), end the line with a trailing backslash - rather than trailing whitespace. -- **Headings use the PR-title casing rule** below. -- **Write in the present tense.** State what *is*, never a change from a prior state ("X does Y", - not "X now does Y" or "X no longer does Z"). This applies to docs and code/workflow comments - alike. Before/after framing belongs in changelogs, commit messages, and PR descriptions, where - the prior state is the point. -- **When a behavior changes, grep for prose asserting the old one.** Comments, diagram labels, - workflow-input descriptions, and audit statements elsewhere may still describe the prior - behavior, and each was accurate when written. No linter catches a claim that is merely untrue, - so this sweep is the only mechanism that will. - -## Sentence structure - -The structural half of ASD-STE100 is the adopted house style for agent-authored prose, and the -controlled dictionary is deliberately not adopted: vocabulary stays unrestricted, structure is -restricted. Each structural rule a pattern can reach lands as a `prose_lint.py` check -incrementally, and this section names each check as it ships. - -- **Short sentences: at most 25 words in one sentence**, ASD-STE100's descriptive cap, checked by - the `sentence-length` rule in `prose_lint.py`. The check is opt-in like `sentence-split`, - because the existing corpus predates the cap and a default gate would fail whole files nobody - is editing. Write new prose under the cap, and scope a run to a change with - `--check sentence-length --diff <base>`. -- **One instruction per sentence.** A procedure step states one action, and a second action is a - second step. No pattern reaches this, so it is authoring discipline with no check. -- **Active voice, imperative mood for procedure steps.** Write "run the gate", never "the gate - should be run". Also authoring discipline, since a reliable passive-voice pattern does not - exist. - -## Comments - -Applies to code and workflow (`#`) comments alike. - -- Comment only when the code does not explain itself, or the logic is genuinely complex. - Self-evident code needs no comment. -- State only the non-obvious *why*, for the human reading *this* project's code now. No - cross-project references, no historic or design narrative, no rule citations. Governance lives - in the fleet's own instruction set, not echoed inline. -- **Keep it short**: one line is the default. A second line is earned only by a constraint the - code cannot otherwise carry. -- **Structured, not prose**: one sentence per line, never wrapped across lines, never a - multi-sentence run-on. A comment that genuinely needs several sentences is several lines, each - one sentence. -- A comment line opening prose starts with a capital. A trailing label, or the version pin an - action-pinning rule requires, does not. -- Mark a sub-topic with `-` after the comment marker (`# -`), only for genuine parallel sub-items - hanging off a lead line, never a continuation of one thought. -- **No file, class, or type header summary blocks.** A type or file gets a comment only for a - specific non-obvious point, never a block restating what it contains (a license or provenance - header a tool or policy requires is not a summary and is unaffected). -- **Never let a comment grow across edits.** Touching code near an existing comment means the - comment comes out the same length or shorter, never one more clause of rationale appended. - -A continuation stays unindented, one sentence per line: - -```text -# Change gate for the compile tests. -# An esp-idf build costs minutes, so gate on what each test covers. -# A diff that cannot be computed runs everything. -``` - -Sub-topics take a `-` after the comment marker, each elaborating a distinct item named in the lead: - -```text -# Source lint plus change-gated compile tests. -# - compile-test builds the external component. -# - template-compile-test builds one example device per template. -``` - -## Character set - -Agent-authored text is ASCII by default: documentation, code, comments, commit messages, and PR -descriptions. A non-ASCII character is read against three tiers, because whether one is -typography or meaning depends on where it sits. A character in no tier is a finding rather than a -silent pass. - -- **Tier 1, never legitimate.** Typography carrying no meaning its ASCII form loses. Remove on - sight: - - em dash (U+2014) and en dash (U+2013) to a restructured sentence, two sentences or a comma, - never a spaced hyphen - - right arrow (U+2192) to `->`, double arrow (U+21D2) to `=>` - - curly quotes (U+2018/U+2019/U+201C/U+201D) to straight `'` and `"` - - ellipsis (U+2026) to `...`, bullet (U+2022) to `-` - - no-break space (U+00A0) to a space, non-breaking hyphen (U+2011) to `-` -- **Tier 2, legitimate only next to a number.** Relational and arithmetic operators: U+2264, - U+2265, U+2260, U+00B1, U+2212, U+00D7, U+00F7, U+00B7. Keep one when an adjacent non-space token - is a number, a tier-3 symbol, or another tier-2 operator, so a threshold table or a measured - range reads as the range it is. In flowing prose write the ASCII form: `<=`, `>=`, `!=`, `+/-`, - `-`, `x`, `/`. A tier-2 operator directly before a number in a table of thresholds is the range - it describes and stays, the same character between two words in a sentence is prose and takes - the ASCII form. -- **Tier 3, always legitimate.** Scientific and unit symbols whose ASCII form would be a lie: - micro (U+00B5), degree (U+00B0), ohm (U+2126), pi (U+03C0), superscript two and three (U+00B2, - U+00B3), section (U+00A7). Keep the symbol, never approximate it away or spell it out. -- **Unicode a developer deliberately typed** stays regardless of tier, such as emoji used for - emphasis or as callout markers. Never strip a developer's own characters, this is developer - authored text and not a license for the agent to add its own. -- **An unrecognized non-ASCII character is reported, not allowed.** Classify it into a tier above - before using it. -- **No semicolon in agent-authored prose.** Recast a mid-sentence semicolon as a comma or as two - sentences. A semicolon separating items in a list that already contains commas, or a statement - terminator in code, is unaffected. -- **No spaced hyphen joining or interrupting a sentence** (` - `, or the paired aside ` - x - `). - Recast as a comma, two sentences, or parentheses. A hyphen inside a compound word, a leading - list marker, a range, and the `- **Label** - explanation` bullet separator are unaffected. -- **In carried verbatim content, fix the whole class at the hub**, not one instance, since a - downstream repo cannot edit a section byte-matched against the hub. Everywhere else, correct as - each file is next edited, not swept. - -## Line endings - -This repo's default is CRLF (`[*] end_of_line = crlf` in `.editorconfig`), with LF pinned for -execution-sensitive files (`*.sh`, Dockerfiles, a shebang-executed `.py` pinned by path). -**Preserve a file's existing line ending when editing it, never reflow as a side effect of a -content change.** A text-mode tool, including a naive programmatic write, can silently flip CRLF -to LF and turn a one-line change into a whole-file diff. After any programmatic edit, verify with -`git diff --stat` (it should touch only the lines you changed) and a byte scan, `file` and a naive -`git ls-files --eol` are both unreliable here. Idempotent normalize: -`b.replace(b"\r\n", b"\n").replace(b"\n", b"\r\n")`. The full policy, choosing an ending for a new -file type, operational-repo overrides, extensionless-script pins, and auditing, is in -`references/line-endings.md`. - -## Carried files reference no coordination machinery - -`AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, `WORKFLOW.md`, `.github/copilot-instructions.md`, -the `repo-config/` and `spec/` files, and the carried `AUDIT.md` never reference the template repo -(in prose or a link), and never name a sibling fleet repo as an illustrative example. State the -behavior a carried rule needs, not the coordination flow that produced it, the maintainer supplies -the destination out of band. A contextually relevant link to a related project (the image this -config feeds, a library this depends on) is not a coordination reference and is expected. The full -exceptions, a verbatim section that must name the hub to do its job, and a pointer to a -hub-hosted tool the reader runs, are in `references/carried-doc-references.md`. - -## PR titles and commit messages - -- **Format**: an imperative subject, 72 characters or fewer, no trailing period ("Add 24-hour - PM2.5 average sensor", not "Added X" or "Adds X"). An optional body, blank-line separated, - explains *why* the change is being made when that is non-obvious, the diff already shows *what*. -- **Rules**: no vague titles (`update stuff`, `wip`). Dependabot's default `Bump X from Y to Z` - titles are fine as-is. No `Co-Authored-By:` lines unless the developer explicitly asks. No - release-bump magnitude in the title ("minor", "patch", "release v0.2.0"), Nerdbank.GitVersioning - computes the next version from `version.json` and git history, a dependency version in a - dependency-bump title is fine and expected. US English spelling, and title case with lowercase - short bind words (a, an, the, and, but, or, of, in, on, at, to, by, for, from), a hyphenated - compound capitalizes both parts unless the second is a short preposition (*Built-in*, - *EPA-Corrected*, *24-Hour*). - -```text -Add structured logging extensions to library -Pin softprops/action-gh-release to commit SHA -Drop net8.0 multi-targeting from console project -Bump xunit.v3 from 3.2.2 to 3.3.0 -Clarify devcontainer setup steps in README -``` - -## Quantitative claims - -A quantitative claim in `README.md` (a count, a size, a version floor, a supported-platform list) -is verified against current code before it is written. When a doc number is derived from a code -constant, mark the dependency in a source-code comment so the next editor knows to update both. +--- +name: comment-and-doc-style +description: >- + Governs prose, comment, Markdown, character-set, line-ending, and PR-title/commit-message + conventions for every ptr727/ProjectTemplate fleet repo. Use this whenever writing or editing a + code comment, workflow comment, Markdown doc, commit message, or PR title, whenever choosing + which characters to type in agent-authored text, whenever the file being edited is CRLF, and + whenever naming a tool in prose or docs. Triggers even when the task looks purely mechanical, + such as "just fix a typo" or "add a one-line comment", because the fleet's ASCII character-set + tiers, no-semicolon rule, comment-growth discipline, and CRLF-preservation rule are each easy to + violate without noticing: an em dash slipped into a sentence, a comment that grew by one more + clause, or a text-mode edit that silently flattens a CRLF file to LF. Also triggers when + authoring a new Markdown file (reference-style links, Table of Contents, present tense), when a + carried instruction file (AGENTS.md, GOVERNANCE.md, CODESTYLE.md, WORKFLOW.md, + .github/copilot-instructions.md) is being edited (no coordination references to the template or + a sibling repo), and when writing a PR title or commit message (imperative subject, no vague + titles, no unsolicited Co-Authored-By, no release-bump magnitude). +--- + +# Comment and Doc Style + +## Why this exists + +These are the fleet's mechanical prose rules, kept in one place instead of re-derived per repo or +per session: how to write a comment, which characters an agent may type, how a Markdown file is +structured, how a carried instruction file may reference the hub, and how a PR title or commit +message reads. None of these are matters of taste. Each is checked, by `prose_lint.py`, +`editorconfig-checker`, `markdownlint`, `cspell`, or a human reviewer, and each has been the exact +subject of a real review finding. + +## Naming tools in prose + +Use each tool's official casing in task labels, docs, and prose: `.NET` (not `.Net`), +`CSharpier`, `ruff`, `pyright`, `uv`. Do not invent personal variants. + +## Markdown files: linting and spelling + +- **Markdown lints clean, repo-wide.** Every `.md` file is error and warning free via + `markdownlint-cli2` against the shared `.markdownlint-cli2.jsonc`. A rule it deliberately + 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. +- **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. +- **CI's spelling gate covers `README.md` and `HISTORY.md` only**, deliberately not every `.md` + file, so a new topical doc is not spell-gated in CI (the editor extension still flags it live). + A repo may widen its own CI list, README plus HISTORY is the default. A repo shipping no + `HISTORY.md` drops it from the CI workflow, the `Lint: Spelling` task, and the GOVERNANCE.md + cspell line together, all three or none. +- **`HISTORY.md` mirrors the README's opening**: the same `# <Title>`, the same tagline verbatim + (the first line after the README's H1), then its own `## Release History`. It never repeats a + paragraph below the README's tagline. +- **"Markdown" is a proper noun in prose** (a Markdown file, a Markdown-only repo), lowercase only + for what a machine reads: a tool or package name (`markdownlint`), a settings key, a heading + anchor, a file extension. + +## Markdown formatting + +- **Reference-style links everywhere**, except the four files read one section at a time rather + than end to end: `AGENTS.md`, `GOVERNANCE.md`, `OPERATIONS.md`, `.github/copilot-instructions.md`. + Those keep inline links so a target resolves where it is read. Every other Markdown file defines + every URI at the bottom, grouped by type under an HTML-comment header, each group alphabetized + by reference name rather than by the full definition line (a name that is a prefix of another + sorts first, `[governance]` above `[governance-branching-model]`). A URL inside a fenced code + block stays inline. See `references/markdown-links.md` for the full grouping and naming + convention. +- **Table of Contents**: generated by the Markdown All in One extension on save, never + hand-authored or hand-edited. Exclude a heading with an inline `<!-- omit from toc -->` marker. +- **One logical paragraph per line**, no hard-wrap line-length limit. For an intentional line + break within a block (stacked badges, status lines), end the line with a trailing backslash + rather than trailing whitespace. +- **Headings use the PR-title casing rule** below. +- **Write in the present tense.** State what *is*, never a change from a prior state ("X does Y", + not "X now does Y" or "X no longer does Z"). This applies to docs and code/workflow comments + alike. Before/after framing belongs in changelogs, commit messages, and PR descriptions, where + the prior state is the point. +- **When a behavior changes, grep for prose asserting the old one.** Comments, diagram labels, + workflow-input descriptions, and audit statements elsewhere may still describe the prior + behavior, and each was accurate when written. No linter catches a claim that is merely untrue, + so this sweep is the only mechanism that will. + +## Sentence structure + +The structural half of ASD-STE100 is the adopted house style for agent-authored prose, and the +controlled dictionary is deliberately not adopted: vocabulary stays unrestricted, structure is +restricted. Each structural rule a pattern can reach lands as a `prose_lint.py` check +incrementally, and this section names each check as it ships. + +- **Short sentences: at most 25 words in one sentence**, ASD-STE100's descriptive cap, checked by + the `sentence-length` rule in `prose_lint.py`. The check is opt-in like `sentence-split`, + because the existing corpus predates the cap and a default gate would fail whole files nobody + is editing. Write new prose under the cap, and scope a run to a change with + `--check sentence-length --diff <base>`. +- **One instruction per sentence.** A procedure step states one action, and a second action is a + second step. No pattern reaches this, so it is authoring discipline with no check. +- **Active voice, imperative mood for procedure steps.** Write "run the gate", never "the gate + should be run". Also authoring discipline, since a reliable passive-voice pattern does not + exist. + +## Comments + +Applies to code and workflow (`#`) comments alike. + +- Comment only when the code does not explain itself, or the logic is genuinely complex. + Self-evident code needs no comment. +- State only the non-obvious *why*, for the human reading *this* project's code now. No + cross-project references, no historic or design narrative, no rule citations. Governance lives + in the fleet's own instruction set, not echoed inline. +- **Keep it short**: one line is the default. A second line is earned only by a constraint the + code cannot otherwise carry. +- **Structured, not prose**: one sentence per line, never wrapped across lines, never a + multi-sentence run-on. A comment that genuinely needs several sentences is several lines, each + one sentence. +- A comment line opening prose starts with a capital. A trailing label, or the version pin an + action-pinning rule requires, does not. +- Mark a sub-topic with `-` after the comment marker (`# -`), only for genuine parallel sub-items + hanging off a lead line, never a continuation of one thought. +- **No file, class, or type header summary blocks.** A type or file gets a comment only for a + specific non-obvious point, never a block restating what it contains (a license or provenance + header a tool or policy requires is not a summary and is unaffected). +- **Never let a comment grow across edits.** Touching code near an existing comment means the + comment comes out the same length or shorter, never one more clause of rationale appended. + +A continuation stays unindented, one sentence per line: + +```text +# Change gate for the compile tests. +# An esp-idf build costs minutes, so gate on what each test covers. +# A diff that cannot be computed runs everything. +``` + +Sub-topics take a `-` after the comment marker, each elaborating a distinct item named in the lead: + +```text +# Source lint plus change-gated compile tests. +# - compile-test builds the external component. +# - template-compile-test builds one example device per template. +``` + +## Character set + +Agent-authored text is ASCII by default: documentation, code, comments, commit messages, and PR +descriptions. A non-ASCII character is read against three tiers, because whether one is +typography or meaning depends on where it sits. A character in no tier is a finding rather than a +silent pass. + +- **Tier 1, never legitimate.** Typography carrying no meaning its ASCII form loses. Remove on + sight: + - em dash (U+2014) and en dash (U+2013) to a restructured sentence, two sentences or a comma, + never a spaced hyphen + - right arrow (U+2192) to `->`, double arrow (U+21D2) to `=>` + - curly quotes (U+2018/U+2019/U+201C/U+201D) to straight `'` and `"` + - ellipsis (U+2026) to `...`, bullet (U+2022) to `-` + - no-break space (U+00A0) to a space, non-breaking hyphen (U+2011) to `-` +- **Tier 2, legitimate only next to a number.** Relational and arithmetic operators: U+2264, + U+2265, U+2260, U+00B1, U+2212, U+00D7, U+00F7, U+00B7. Keep one when an adjacent non-space token + is a number, a tier-3 symbol, or another tier-2 operator, so a threshold table or a measured + range reads as the range it is. In flowing prose write the ASCII form: `<=`, `>=`, `!=`, `+/-`, + `-`, `x`, `/`. A tier-2 operator directly before a number in a table of thresholds is the range + it describes and stays, the same character between two words in a sentence is prose and takes + the ASCII form. +- **Tier 3, always legitimate.** Scientific and unit symbols whose ASCII form would be a lie: + micro (U+00B5), degree (U+00B0), ohm (U+2126), pi (U+03C0), superscript two and three (U+00B2, + U+00B3), section (U+00A7). Keep the symbol, never approximate it away or spell it out. +- **Unicode a developer deliberately typed** stays regardless of tier, such as emoji used for + emphasis or as callout markers. Never strip a developer's own characters, this is developer + authored text and not a license for the agent to add its own. +- **An unrecognized non-ASCII character is reported, not allowed.** Classify it into a tier above + before using it. +- **No semicolon in agent-authored prose.** Recast a mid-sentence semicolon as a comma or as two + sentences. A semicolon separating items in a list that already contains commas, or a statement + terminator in code, is unaffected. +- **No spaced hyphen joining or interrupting a sentence** (` - `, or the paired aside ` - x - `). + Recast as a comma, two sentences, or parentheses. A hyphen inside a compound word, a leading + list marker, a range, and the `- **Label** - explanation` bullet separator are unaffected. +- **In carried verbatim content, fix the whole class at the hub**, not one instance, since a + downstream repo cannot edit a section byte-matched against the hub. Everywhere else, correct as + each file is next edited, not swept. + +## Line endings + +This repo's default is LF (`[*] end_of_line = lf` in `.editorconfig`), with CRLF pinned only for +`*.bat` and `*.cmd`, the one type Windows itself requires it for. +**Preserve a file's existing line ending when editing it, never reflow as a side effect of a +content change.** A text-mode tool, including a naive programmatic write, can silently flip CRLF +to LF and turn a one-line change into a whole-file diff. After any programmatic edit, verify with +`git diff --stat` (it should touch only the lines you changed) and a byte scan, `file` and a naive +`git ls-files --eol` are both unreliable here. Idempotent normalize: +`b.replace(b"\r\n", b"\n").replace(b"\n", b"\r\n")`. The full policy, choosing an ending for a new +file type, operational-repo overrides, extensionless-script pins, and auditing, is in +`references/line-endings.md`. + +## Carried files reference no coordination machinery + +`AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, `WORKFLOW.md`, `.github/copilot-instructions.md`, +the `repo-config/` and `spec/` files, and the carried `AUDIT.md` never reference the template repo +(in prose or a link), and never name a sibling fleet repo as an illustrative example. State the +behavior a carried rule needs, not the coordination flow that produced it, the maintainer supplies +the destination out of band. A contextually relevant link to a related project (the image this +config feeds, a library this depends on) is not a coordination reference and is expected. The full +exceptions, a verbatim section that must name the hub to do its job, and a pointer to a +hub-hosted tool the reader runs, are in `references/carried-doc-references.md`. + +## PR titles and commit messages + +- **Format**: an imperative subject, 72 characters or fewer, no trailing period ("Add 24-hour + PM2.5 average sensor", not "Added X" or "Adds X"). An optional body, blank-line separated, + explains *why* the change is being made when that is non-obvious, the diff already shows *what*. +- **Rules**: no vague titles (`update stuff`, `wip`). Dependabot's default `Bump X from Y to Z` + titles are fine as-is. No `Co-Authored-By:` lines unless the developer explicitly asks. No + release-bump magnitude in the title ("minor", "patch", "release v0.2.0"), Nerdbank.GitVersioning + computes the next version from `version.json` and git history, a dependency version in a + dependency-bump title is fine and expected. US English spelling, and title case with lowercase + short bind words (a, an, the, and, but, or, of, in, on, at, to, by, for, from), a hyphenated + compound capitalizes both parts unless the second is a short preposition (*Built-in*, + *EPA-Corrected*, *24-Hour*). + +```text +Add structured logging extensions to library +Pin softprops/action-gh-release to commit SHA +Drop net8.0 multi-targeting from console project +Bump xunit.v3 from 3.2.2 to 3.3.0 +Clarify devcontainer setup steps in README +``` + +## Quantitative claims + +A quantitative claim in `README.md` (a count, a size, a version floor, a supported-platform list) +is verified against current code before it is written. When a doc number is derived from a code +constant, mark the dependency in a source-code comment so the next editor knows to update both. diff --git a/.claude-plugin/fleet-skills/skills/comment-and-doc-style/references/carried-doc-references.md b/.claude-plugin/fleet-skills/skills/comment-and-doc-style/references/carried-doc-references.md index 111d6e88..972c63f5 100644 --- a/.claude-plugin/fleet-skills/skills/comment-and-doc-style/references/carried-doc-references.md +++ b/.claude-plugin/fleet-skills/skills/comment-and-doc-style/references/carried-doc-references.md @@ -1,61 +1,61 @@ -# Carried Files Carry No Coordination References - -Full detail for the "Carried files reference no coordination machinery" rule in `SKILL.md`. Load -this when editing one of the carried files themselves, not when writing an ordinary repo-owned -doc. - -## Which files this governs - -`AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, `WORKFLOW.md`, `.github/copilot-instructions.md`, -the `repo-config/` and `spec/` files, and the carried `AUDIT.md`, the files the fleet carries -verbatim or at `intent` fidelity from the hub into every repo. This rule governs carried template -content only. A repo's own `README.md` and topical docs are its own content, never carried -verbatim, and this rule does not reach them. - -## What is banned - -Two 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 - naming where a file came from is exactly the derived-from framing the present-tense rule (in - `SKILL.md`'s "Markdown formatting" section) independently forbids. Where a carried file must - express a template-level behavior ("report a rule discrepancy upstream"), state the behavior - rather than the destination. The maintainer supplies the destination out of band. -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. - -## The two exceptions - -**The first exception is a verbatim section**, and `AGENTS.md` "Fleet Bootstrap" is why it exists. -That section's whole function is to name where the canonical rules live, for an agent in a -repository whose carried copies are stale, partial, or absent, which is exactly when no other file -present can say it. Its bytes are fixed fleet-wide, so a repository cannot edit the reference out -without failing the verbatim check instead, and a rule banning it would be unsatisfiable rather -than merely strict. The exception is scoped to the verbatim region and never leaks past it: the -same document's own prose, outside that region, is governed normally. A reference that reaches a -verbatim section is a defect in the canonical, fixed once at the source rather than reported -against every repository carrying it. - -**The second exception is a hub-hosted tool the reader is told to run**, which is a different kind -of reference. A rule naming a gate, a script, or a reference snippet the reader executes or copies -states an instruction rather than a provenance, and an instruction with no destination is -unfollowable, which is precisely how a pointer in carried text comes to read as decorative. The -test is whether the reference is something the reader *does* or something that *happened to this -file*: where the content came from stays out, what the reader runs stays in. Such a pointer names -the hub's canonical rather than this repository's provenance, so it is the hub's to keep resolving -and never a repository's to edit out or re-point at a local path. What is reached rather than -carried, and how, is `GOVERNANCE.md` "Hub-Hosted Tooling". In `AGENTS.md` and `GOVERNANCE.md` this -belongs in verbatim rule text, the same region the first exception already covers, so the whole -fleet reads one wording and no repository is asked to answer for a reference it did not write. - -## What is not a coordination reference - -**A contextually relevant link to a related project is expected, not banned.** Where another repo -is part of this repo's subject matter (the image that consumes this config, the builder that -generates this hardware, a library this depends on), link it normally. The test is whether the -link serves a reader of *this* repo's content, not whether the target happens to be in the fleet. - -This pairs with the present-tense rule: state the current shape, not a history of which repo it -came from. +# Carried Files Carry No Coordination References + +Full detail for the "Carried files reference no coordination machinery" rule in `SKILL.md`. Load +this when editing one of the carried files themselves, not when writing an ordinary repo-owned +doc. + +## Which files this governs + +`AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, `WORKFLOW.md`, `.github/copilot-instructions.md`, +the `repo-config/` and `spec/` files, and the carried `AUDIT.md`, the files the fleet carries +verbatim or at `intent` fidelity from the hub into every repo. This rule governs carried template +content only. A repo's own `README.md` and topical docs are its own content, never carried +verbatim, and this rule does not reach them. + +## What is banned + +Two 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 + naming where a file came from is exactly the derived-from framing the present-tense rule (in + `SKILL.md`'s "Markdown formatting" section) independently forbids. Where a carried file must + express a template-level behavior ("report a rule discrepancy upstream"), state the behavior + rather than the destination. The maintainer supplies the destination out of band. +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. + +## The two exceptions + +**The first exception is a verbatim section**, and `AGENTS.md` "Fleet Bootstrap" is why it exists. +That section's whole function is to name where the canonical rules live, for an agent in a +repository whose carried copies are stale, partial, or absent, which is exactly when no other file +present can say it. Its bytes are fixed fleet-wide, so a repository cannot edit the reference out +without failing the verbatim check instead, and a rule banning it would be unsatisfiable rather +than merely strict. The exception is scoped to the verbatim region and never leaks past it: the +same document's own prose, outside that region, is governed normally. A reference that reaches a +verbatim section is a defect in the canonical, fixed once at the source rather than reported +against every repository carrying it. + +**The second exception is a hub-hosted tool the reader is told to run**, which is a different kind +of reference. A rule naming a gate, a script, or a reference snippet the reader executes or copies +states an instruction rather than a provenance, and an instruction with no destination is +unfollowable, which is precisely how a pointer in carried text comes to read as decorative. The +test is whether the reference is something the reader *does* or something that *happened to this +file*: where the content came from stays out, what the reader runs stays in. Such a pointer names +the hub's canonical rather than this repository's provenance, so it is the hub's to keep resolving +and never a repository's to edit out or re-point at a local path. What is reached rather than +carried, and how, is `GOVERNANCE.md` "Hub-Hosted Tooling". In `AGENTS.md` and `GOVERNANCE.md` this +belongs in verbatim rule text, the same region the first exception already covers, so the whole +fleet reads one wording and no repository is asked to answer for a reference it did not write. + +## What is not a coordination reference + +**A contextually relevant link to a related project is expected, not banned.** Where another repo +is part of this repo's subject matter (the image that consumes this config, the builder that +generates this hardware, a library this depends on), link it normally. The test is whether the +link serves a reader of *this* repo's content, not whether the target happens to be in the fleet. + +This pairs with the present-tense rule: state the current shape, not a history of which repo it +came from. diff --git a/.claude-plugin/fleet-skills/skills/comment-and-doc-style/references/line-endings.md b/.claude-plugin/fleet-skills/skills/comment-and-doc-style/references/line-endings.md index 63f47774..7f54f939 100644 --- a/.claude-plugin/fleet-skills/skills/comment-and-doc-style/references/line-endings.md +++ b/.claude-plugin/fleet-skills/skills/comment-and-doc-style/references/line-endings.md @@ -1,132 +1,135 @@ -# Line Ending Policy - -Full detail for the "Line endings" rule in `SKILL.md`. Load this when choosing an ending for a -new file type, working in an operational (config) repo, pinning an extensionless executable, or -auditing a repo's endings, not for an ordinary content edit to an existing file (the SKILL.md -summary, preserve the existing ending and verify with a byte scan, covers that case). - -## The defaults - -- **`.editorconfig` sets the line ending.** `[*] end_of_line = crlf` - is the default, every file type is CRLF unless pinned otherwise, with LF pinned for the - execution-sensitive exceptions: `*.sh`, Dockerfiles, and any individual `.py` executed directly - via its shebang (pinned by path, for example `spec/validate.py`, vanilla `.py` stays CRLF, since - Python's universal newlines accept it and it is commonly edited on Windows). Only the LF - exceptions are declared, the redundant per-type CRLF rules are intentionally omitted. -- **`.gitattributes` mirrors it**: `* -text` (git stores the exact bytes committed and does not - normalize) plus the matching LF pins. -- **Both files are required together.** `.editorconfig` governs the editor, `.gitattributes` - governs git (checkout, commit, `--renormalize`). A repo missing either file, or whose - `.editorconfig` sets no global `end_of_line` default (for example declares it only under - `[*.md]`), accumulates files mixed between LF and CRLF, the exact failure these two files - prevent together. Carry both files whole (an inert `[*.cs]` block costs nothing in a non-.NET - repo), including the `*.sh text eol=lf` pin and any extensionless-script path pins. - -## Choosing an ending for a new file type - -CRLF is the default, since cross-platform editors on Windows produce it and it is harmless on -Linux for everything except shell. Use LF only when the type requires it or CRLF breaks how it is -consumed: executable scripts and shebangs (`*.sh`, s6, husky), Dockerfiles (CRLF breaks `RUN` -heredocs and line continuations), and tool-owned formats with a native LF ending (KiCad). -Non-workflow YAML stays CRLF, GitHub Actions' parser tolerates it (a repo also running yamllint -sets `new-lines: disable` to defer to `.editorconfig`). Workflow YAML -(`.github/workflows/*.{yml,yaml}`) is pinned LF in `.editorconfig`, because Dependabot and Actions -rewrite it with LF, so declaring LF keeps it consistent instead of mixed on every bump. This LF -class is not backed by a `.gitattributes` pin, git keeps `* -text`, and CI's `editorconfig-checker` -(EOL-only) catches a mismatch instead. - -Distinguish where a file is *consumed* from where it is *edited*, consumption on Linux alone does -not force LF. A config or pattern file consumed by a Linux tool stays CRLF when the tool tolerates -a trailing CR: `.dockerignore` and `.gitignore` are CRLF (their parsers strip the CR), and only a -*Dockerfile*, interpreted and broken by a CR in a `RUN` heredoc or line continuation, is LF. - -## Operational (config) repos - -The global default follows the consuming application's native platform, not the fleet CRLF -default. A config repo (registry `workflowModel: operational`) is a view into an application's -configuration directory, often the exact tree mounted into that app's container, so its files use -the ending the app itself reads and writes, and forcing the fleet CRLF default would fight the -app. Set the `[*] end_of_line` default to the app's native ending and record it in the registry -`lineEndings` field (`lf` or `crlf`): LF for a Linux-native app whose config lives in a Linux -container (ESPHome, Home Assistant, a devcontainer-only or HACS config), CRLF for a Windows-native -editor, for example Vantage InFusion config edited by Design Center on Windows. The -execution-sensitive LF pins (`*.sh`, Dockerfiles, workflow YAML) still apply on top, and -`.gitattributes` still mirrors the chosen default. This override is for operational repos only, -`release` repos keep the `[*] end_of_line = crlf` fleet default above. Do not re-normalize an -operational repo to the fleet default, that is exactly the over-normalization these per-repo -endings exist to prevent. - -**Mixed-consumer config: prefer to split by platform into single-platform repos, not one mixed -repo.** When a config repo would be consumed on two platforms (a Linux app plus a Windows-edited -subtree), the clean answer is a repo per consumer, each single-platform with its own -`lineEndings`. For example a controller config edited by a Windows-native editor lives in its own -CRLF repo, not as a subtree inside a Linux `lf` config repo. Fallback only if a subtree genuinely -cannot be split out: keep the global default at the primary consumer and pin the odd subtree with -an `.editorconfig` path override (for example `[<subtree>/**] end_of_line = crlf`) matching its -consumer. The global `* -text` in `.gitattributes` already preserves those bytes, no extra git pin -is needed. - -## Scripts and extensionless executables - -Must be LF, and pinned in `.gitattributes`, not just configured. A CRLF shebang -(`#!/usr/bin/env bash\r`) breaks execution. `.editorconfig` sets `[*.sh] = lf`, but that -extension-based rule does not match extensionless executables (s6 service scripts `run`/`up`/ -`finish`, husky or git hook scripts like `.husky/pre-commit`), and `* -text` enforces nothing, so -a broad normalization pass or an editor can silently flip them to CRLF. `.gitattributes` is the -enforcement layer: it carries `*.sh text eol=lf`, and any repo whose tooling ships extensionless -scripts adds the matching path pin (`Docker/s6-overlay/** text eol=lf` for s6 init, -`.husky/pre-commit text eol=lf` for husky hooks), so git holds them at LF on checkout and -`--renormalize`. This pin is mandatory for any repo that overrides s6 init, uses husky or git -hooks, or otherwise ships executable scripts. The same explicit-pin rule extends to tool-owned -file formats the base config does not key on: pin them to whatever ending the tool reads and -writes, for example KiCad project, footprint, and 3D files (`*.kicad_mod`, `*.kicad_sym`, -`*.step`), which KiCad writes LF. - -**Pair each such pin with a matching `.editorconfig` override**, since the git pin alone is not -enough, `.gitattributes` governs git while the editor follows `.editorconfig`, where the default -still applies to any file no extension rule covers. Give every extensionless executable an -editorconfig LF override beside its `.gitattributes` pin (`[.husky/pre-commit] end_of_line = lf`), -and for a byte-preserve data directory (downloaded or opaque source whose exact bytes the consumer -may depend on) disable all editor normalization, not just EOL: `[<dir>/*]` with `charset = unset`, -`end_of_line = unset`, `insert_final_newline = false`, `trim_trailing_whitespace = false` (`unset` -is EditorConfig's spec-defined special value that removes an inherited property). - -## Editing discipline - -- **New files**: create with the `.editorconfig`-mandated ending. -- **Editing an existing file**: preserve its current line endings, do not reflow them as a side - effect of a content change, even if the file is already non-compliant. A tool that rewrites a - file in text mode (a script, a bulk find/replace) can silently flip CRLF to LF and turn a - one-line change into a whole-file diff. After any programmatic edit, verify before staging: - `git diff --stat` should touch only the lines you changed, and a byte check should confirm the - expected ending. If a diff balloons to the whole file, the endings flipped, restore them and - re-stage. -- **Fixing a non-compliant file**: bring it to its `.editorconfig` ending as a deliberate change, - and prefer to isolate it in its own EOL-only commit so the churn is reviewable. When a broader - maintenance change has to normalize endings alongside content edits, call it out explicitly in - the commit or PR description and verify the content separately with - `git diff --ignore-cr-at-eol`. - -## Auditing - -Don't trust `file` or a naive `git ls-files --eol`. The authoritative check is a byte scan that -classifies by which endings are present: CRLF-only (every `\n` preceded by `\r`), LF-only (no -`\r`), or mixed (both forms present). Flag mixed explicitly rather than lumping it in with CRLF, -and skip binaries via a NUL-byte check. `file` mislabels some types (it reports a CRLF `.json` or -`.code-workspace` as plain "JSON text data" with no CRLF note), and `git ls-files --eol`'s `attr/` -column holds multiple tokens that shift naive field-splitting into false positives. Scope a -repo-wide audit to `git ls-files` plus `git ls-files --others --exclude-standard`, never a raw -`find`, which sweeps self-ignoring caches (`.mypy_cache`, `.artifacts`). - -Idempotent normalize: `b.replace(b"\r\n", b"\n").replace(b"\n", b"\r\n")`. A single within-line -string replace is EOL-safe, but a tool that inserts multiple lines or writes a new file into a -CRLF file must emit `\r\n`, since a naive `\n` insert creates mixed endings. `.code-workspace` is -JSONC (it has `//` comments), so strip them before JSON-parsing it. - -Editing CRLF files programmatically with a regex has a sharper trap: `.` matches `\r`, so a -captured line keeps its carriage return and rejoining with `\r\n` yields `CRCRLF`. A text-mode -rewrite has the mirror failure, silently flattening CRLF to LF. Prefer line-based edits -(`splitlines(keepends=True)`) or literal replacement over regex reassembly. In Python the -text-mode failure is the default: `Path.read_text()` decodes through universal newlines and -`write_text()` writes `\n` back, so a read-edit-write round trip flattens the whole file while the -edit itself looks correct. Pass `newline=''` to both, or work in bytes. +# Line Ending Policy + +Full detail for the "Line endings" rule in `SKILL.md`. Load this when choosing an ending for a +new file type, working in an operational (config) repo, pinning an extensionless executable, or +auditing a repo's endings, not for an ordinary content edit to an existing file (the SKILL.md +summary, preserve the existing ending and verify with a byte scan, covers that case). + +## The defaults + +- **`.editorconfig` sets the line ending.** `[*] end_of_line = lf` is the default, every file + type is LF unless pinned otherwise, with CRLF pinned for the one exception Windows requires: + `*.bat` and `*.cmd` (cmd.exe's line handling is unreliable on LF). Only the CRLF exception is + declared, the redundant per-type LF rules are intentionally omitted, since the default already + gives shell scripts, Dockerfiles, workflow YAML, `uv.lock`, and every shebang-executed `.py` + the ending they need without a path-specific pin. +- **`.gitattributes` mirrors the execution-sensitive classes**: `* -text` (git stores the exact + bytes committed and does not normalize) plus explicit `text eol=lf` pins for `*.sh`, Dockerfiles, + `uv.lock`, and the shebang-executed `.py` by-path list. Those pins are now redundant with the + `[*]` LF default above, since every path they name already gets LF from it, and they stay + regardless, as git-level enforcement independent of the editor (a re-normalization tool or a + CRLF-configured git client that does not consult `.editorconfig`). Do not add a new + `.gitattributes` pin for a path that only needs the default, `.editorconfig`'s `[*]` already + covers it. Add one only for a genuinely execution-sensitive path where losing LF breaks + execution, matching the existing classes. +- **Both files are required together.** `.editorconfig` governs the editor, `.gitattributes` + governs git (checkout, commit, `--renormalize`). A repo missing either file, or whose + `.editorconfig` sets no global `end_of_line` default (for example declares it only under + `[*.md]`), accumulates files mixed between LF and CRLF, the exact failure these two files + prevent together. Carry both files whole (an inert `[*.cs]` block costs nothing in a non-.NET + repo), including the `*.sh text eol=lf` pin and any extensionless-script path pins. + +## Choosing an ending for a new file type + +LF is the default, since it is what every tool, CI runner, and Dependabot bump produces, and +Windows GUI editors (VS Code, Visual Studio, Notepad, WordPad) all read and write it cleanly. Pin +CRLF only for a type Windows itself requires it for: `*.bat` and `*.cmd`. Everything else, +including YAML (workflow and non-workflow alike, no distinction needed now that both are LF), +`.gitignore`, `.dockerignore`, and a tool-owned format with a native LF ending (KiCad), takes the +`[*]` default with no override. + +## Operational (config) repos + +The global default follows the consuming application's native platform, not the fleet LF default. +A config repo (registry `workflowModel: operational`) is a view into an application's +configuration directory, often the exact tree mounted into that app's container, so its files use +the ending the app itself reads and writes, and forcing the fleet LF default would fight an app +that needs CRLF. Set the `[*] end_of_line` default to the app's native ending and record it in the +registry `lineEndings` field (`lf` or `crlf`): the field is still required for every operational +repo regardless of value, since a config repo's ending is a load-bearing decision tied to its +consuming app rather than something to leave implicit, exactly the reasoning this fleet-wide +default flip demonstrates. Most operational repos need no override today: a Linux-native app whose +config lives in a Linux container (ESPHome, Home Assistant, a devcontainer-only or HACS config) +already matches the new `lf` fleet default. CRLF is still declared for a genuinely Windows-native +editor, for example Vantage InFusion config edited by Design Center on Windows. `release` repos +keep the `[*] end_of_line = lf` fleet default above. Do not re-normalize an operational repo to +the fleet default, that is exactly the over-normalization these per-repo endings exist to +prevent, whichever direction the fleet default currently points. + +**Mixed-consumer config: prefer to split by platform into single-platform repos, not one mixed +repo.** When a config repo would be consumed on two platforms (a Linux app plus a Windows-edited +subtree), the clean answer is a repo per consumer, each single-platform with its own +`lineEndings`. For example a controller config edited by a Windows-native editor lives in its own +CRLF repo, not as a subtree inside a Linux `lf` config repo. Fallback only if a subtree genuinely +cannot be split out: keep the global default at the primary consumer and pin the odd subtree with +an `.editorconfig` path override (for example `[<subtree>/**] end_of_line = crlf`) matching its +consumer. The global `* -text` in `.gitattributes` already preserves those bytes, no extra git pin +is needed. + +## Scripts and extensionless executables + +Must be LF. A CRLF shebang (`#!/usr/bin/env bash\r`) breaks execution, and the `[*]` LF default +already covers an extensionless executable (s6 service scripts `run`/`up`/`finish`, husky or git +hook scripts like `.husky/pre-commit`) without needing a path-specific `.editorconfig` override, +since EditorConfig's `[*]` matches any filename including one with no extension. The remaining +risk is `.gitattributes`, whose `* -text` enforces nothing on its own, so a broad normalization +pass or a CRLF-configured git client can still flip such a file on checkout regardless of what +the editor writes. Pin it there: any repo whose tooling ships extensionless scripts adds the +matching path pin (`Docker/s6-overlay/** text eol=lf` for s6 init, `.husky/pre-commit text +eol=lf` for husky hooks), so git holds them at LF on checkout and `--renormalize` independent of +the editor default. This pin is mandatory for any repo that overrides s6 init, uses husky or git +hooks, or otherwise ships executable scripts. The same explicit-pin rule extends to tool-owned +file formats the base config does not key on: pin them to whatever ending the tool reads and +writes, for example KiCad project, footprint, and 3D files (`*.kicad_mod`, `*.kicad_sym`, +`*.step`), which KiCad writes LF (already the fleet default, so these need no override either +today, but stay pinned since a tool-owned ending is a fact about the tool, not a coincidence with +whatever the fleet default currently is). + +For a type that genuinely needs an ending the `[*]` default no longer supplies (a Windows-native +tool-owned format outside `.bat`/`.cmd`, or a byte-preserve data directory whose exact bytes the +consumer may depend on), still pair a `.gitattributes` pin with a matching `.editorconfig` +override, since the git pin alone is not enough there, `.gitattributes` governs git while the +editor follows `.editorconfig`. For a byte-preserve directory, disable all editor normalization, +not just EOL: `[<dir>/*]` with `charset = unset`, `end_of_line = unset`, `insert_final_newline = +false`, `trim_trailing_whitespace = false` (`unset` is EditorConfig's spec-defined special value +that removes an inherited property). + +## Editing discipline + +- **New files**: create with the `.editorconfig`-mandated ending. +- **Editing an existing file**: preserve its current line endings, do not reflow them as a side + effect of a content change, even if the file is already non-compliant. A tool that rewrites a + file in text mode (a script, a bulk find/replace) can silently flip CRLF to LF and turn a + one-line change into a whole-file diff. After any programmatic edit, verify before staging: + `git diff --stat` should touch only the lines you changed, and a byte check should confirm the + expected ending. If a diff balloons to the whole file, the endings flipped, restore them and + re-stage. +- **Fixing a non-compliant file**: bring it to its `.editorconfig` ending as a deliberate change, + and prefer to isolate it in its own EOL-only commit so the churn is reviewable. When a broader + maintenance change has to normalize endings alongside content edits, call it out explicitly in + the commit or PR description and verify the content separately with + `git diff --ignore-cr-at-eol`. + +## Auditing + +Don't trust `file` or a naive `git ls-files --eol`. The authoritative check is a byte scan that +classifies by which endings are present: CRLF-only (every `\n` preceded by `\r`), LF-only (no +`\r`), or mixed (both forms present). Flag mixed explicitly rather than lumping it in with CRLF, +and skip binaries via a NUL-byte check. `file` mislabels some types (it reports a CRLF `.json` or +`.code-workspace` as plain "JSON text data" with no CRLF note), and `git ls-files --eol`'s `attr/` +column holds multiple tokens that shift naive field-splitting into false positives. Scope a +repo-wide audit to `git ls-files` plus `git ls-files --others --exclude-standard`, never a raw +`find`, which sweeps self-ignoring caches (`.mypy_cache`, `.artifacts`). + +Idempotent normalize: `b.replace(b"\r\n", b"\n").replace(b"\n", b"\r\n")`. A single within-line +string replace is EOL-safe, but a tool that inserts multiple lines or writes a new file into a +CRLF file must emit `\r\n`, since a naive `\n` insert creates mixed endings. `.code-workspace` is +JSONC (it has `//` comments), so strip them before JSON-parsing it. + +Editing CRLF files programmatically with a regex has a sharper trap: `.` matches `\r`, so a +captured line keeps its carriage return and rejoining with `\r\n` yields `CRCRLF`. A text-mode +rewrite has the mirror failure, silently flattening CRLF to LF. Prefer line-based edits +(`splitlines(keepends=True)`) or literal replacement over regex reassembly. In Python the +text-mode failure is the default: `Path.read_text()` decodes through universal newlines and +`write_text()` writes `\n` back, so a read-edit-write round trip flattens the whole file while the +edit itself looks correct. Pass `newline=''` to both, or work in bytes. diff --git a/.claude-plugin/fleet-skills/skills/comment-and-doc-style/references/markdown-links.md b/.claude-plugin/fleet-skills/skills/comment-and-doc-style/references/markdown-links.md index e7251ccb..c3cd7e1a 100644 --- a/.claude-plugin/fleet-skills/skills/comment-and-doc-style/references/markdown-links.md +++ b/.claude-plugin/fleet-skills/skills/comment-and-doc-style/references/markdown-links.md @@ -1,64 +1,64 @@ -# Reference-Style Links - -Full detail for the "Markdown formatting" reference-style-links rule in `SKILL.md`. Load this -when actually authoring or reorganizing a Markdown file's link definitions, not for a small -in-place prose edit. - -## Where the rule applies - -Every Markdown file in the repo uses reference-style links only, except the four files that are -read one section at a time rather than end to end: `AGENTS.md`, `GOVERNANCE.md`, `OPERATIONS.md`, -and `.github/copilot-instructions.md`. Those keep inline `[text](uri)` links, since a reader -jumping straight to one section needs the target to resolve where it is, while a definition parked -at the bottom of the file is never reached. The exception is that closed list of four files, never -a category to argue from case by case. Every other Markdown file follows the rule regardless of -its audience. - -## The definition block - -Every URI, an internal path, an anchor, an external URL, or a shield image, is defined at the -bottom of the file, split into groups by type under an HTML-comment header, for example: - -```markdown -<!-- Shields --> - -[license-shield]: https://img.shields.io/... - -<!-- Repo --> - -[governance]: ./GOVERNANCE.md -[governance-branching-model]: ./GOVERNANCE.md#branching-model - -<!-- External --> - -[markdownlint-cli2]: https://github.com/DavidAnson/markdownlint-cli2 -``` - -Within a group, definitions are alphabetized by **reference name alone**, the text inside the -brackets, never by the whole definition line. Where one name is a prefix of another, the shorter -one sorts first: `[governance]` above `[governance-branching-model]`, `[repo-config]` above -`[repo-config-settings]`. Sorting the full line instead inverts every such pair, because `-` -precedes `]` in byte order, so the two readings disagree on exactly the names a reader looks up -together, and a plain `sort -c` over the block passes on the inverted order regardless. - -## Naming a reference - -Reference names are contextual and encode both the target and its group: - -- `foo-shield` for a shield image -- `foo-link` for an external URL -- a bare `foo` for a local path or anchor - -For example `[license-shield]`, `[releases-link]`, `[repo-config]`. Never a numeric name (`[1]`) -and never an opaque one. - -## Mechanics - -- No inline `[text](uri)` targets in prose, in any file outside the four-file exception above. -- **A URL inside a fenced code block stays inline.** Reference links do not resolve inside a code - block, so do not extract it there, and exclude fenced code from any link-integrity check - (bracket literals like `["a", "b"]` otherwise read as undefined references). -- **Removing a link also removes its reference definition.** An orphaned definition fails the - no-unused-defs rule. -- The one exception to "no inline links" is the Table of Contents, whose entries stay inline - anchor links, since the ToC extension generates them that way and they are never hand-edited. +# Reference-Style Links + +Full detail for the "Markdown formatting" reference-style-links rule in `SKILL.md`. Load this +when actually authoring or reorganizing a Markdown file's link definitions, not for a small +in-place prose edit. + +## Where the rule applies + +Every Markdown file in the repo uses reference-style links only, except the four files that are +read one section at a time rather than end to end: `AGENTS.md`, `GOVERNANCE.md`, `OPERATIONS.md`, +and `.github/copilot-instructions.md`. Those keep inline `[text](uri)` links, since a reader +jumping straight to one section needs the target to resolve where it is, while a definition parked +at the bottom of the file is never reached. The exception is that closed list of four files, never +a category to argue from case by case. Every other Markdown file follows the rule regardless of +its audience. + +## The definition block + +Every URI, an internal path, an anchor, an external URL, or a shield image, is defined at the +bottom of the file, split into groups by type under an HTML-comment header, for example: + +```markdown +<!-- Shields --> + +[license-shield]: https://img.shields.io/... + +<!-- Repo --> + +[governance]: ./GOVERNANCE.md +[governance-branching-model]: ./GOVERNANCE.md#branching-model + +<!-- External --> + +[markdownlint-cli2]: https://github.com/DavidAnson/markdownlint-cli2 +``` + +Within a group, definitions are alphabetized by **reference name alone**, the text inside the +brackets, never by the whole definition line. Where one name is a prefix of another, the shorter +one sorts first: `[governance]` above `[governance-branching-model]`, `[repo-config]` above +`[repo-config-settings]`. Sorting the full line instead inverts every such pair, because `-` +precedes `]` in byte order, so the two readings disagree on exactly the names a reader looks up +together, and a plain `sort -c` over the block passes on the inverted order regardless. + +## Naming a reference + +Reference names are contextual and encode both the target and its group: + +- `foo-shield` for a shield image +- `foo-link` for an external URL +- a bare `foo` for a local path or anchor + +For example `[license-shield]`, `[releases-link]`, `[repo-config]`. Never a numeric name (`[1]`) +and never an opaque one. + +## Mechanics + +- No inline `[text](uri)` targets in prose, in any file outside the four-file exception above. +- **A URL inside a fenced code block stays inline.** Reference links do not resolve inside a code + block, so do not extract it there, and exclude fenced code from any link-integrity check + (bracket literals like `["a", "b"]` otherwise read as undefined references). +- **Removing a link also removes its reference definition.** An orphaned definition fails the + no-unused-defs rule. +- The one exception to "no inline links" is the Table of Contents, whose entries stay inline + anchor links, since the ToC extension generates them that way and they are never hand-edited. diff --git a/.claude-plugin/fleet-skills/skills/copilot-instructions-keeper/SKILL.md b/.claude-plugin/fleet-skills/skills/copilot-instructions-keeper/SKILL.md index e16f76c5..b9d0154d 100644 --- a/.claude-plugin/fleet-skills/skills/copilot-instructions-keeper/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/copilot-instructions-keeper/SKILL.md @@ -1,95 +1,95 @@ ---- -name: copilot-instructions-keeper -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. ---- - -# Copilot Instructions Keeper - -## Why this exists - -`.github/copilot-instructions.md` is read directly by the GitHub Copilot review bot, which -cannot read a `SKILL.md`. Unlike other carried governance content, its rule text is never -pointer-ized into a skill, this file always stays fully intact in every repo that carries it. -This skill is about maintaining that file's carried copy, not a substitute for it. - -`spec/files.json` declares it `intent` fidelity, `whole: true`, covering three named sections -(`Commit Messages and Pull Request Titles`, `Reviewing Carried Fleet Content`, `GitHub Copilot -Review Runbook`), with `<owner>`, `<repo>`, and `<N>` placeholders filled per repo. **The fleet -audit checks an `intent` file for file presence and each named section's heading, never for -content drift inside a section.** A section that is present but has fallen out of date against -the hub, the exact gap this skill exists to catch, produces no finding anywhere in the mechanical -audit. Noticing that has to happen in a live session like this one. - -## The one thing this file has that others don't: repo-local ledger entries - -The file's own "Disproved Claims" section states its rule plainly. **The section's shape and -governing rules are carried, but its entries are not.** Each entry records a finding that was -raised against this specific repository and disproved against this repository's code at a named -revision. A repository carrying a copy of this file carries the shape and rules, deletes any -entry whose subject it does not hold, and records what it has proved for itself. - -This means a blind re-vendor of the hub's canonical `.github/copilot-instructions.md` over a downstream -repo's copy is wrong in both directions: - -- Copying the hub's own "Disproved Claims" entries (about `ProjectTemplate` itself) into a - downstream repo attaches proofs about code that repo does not carry. -- Overwriting a downstream repo's copy wholesale deletes any entries that repo itself has earned, - a live disproof, run against that repo's own tree, thrown away with no record. - -**Before touching this file in any repo other than the hub itself:** - -1. Read the current "Disproved Claims" section in that repo's copy, if it has one, and preserve - every entry that names a file or behavior that repo actually carries. -2. Update everything else, the runbook mechanics, the three named sections, the rule text, to - match the hub canonical. -3. Never carry the hub's own repo-specific "Disproved Claims" entries downstream. They name - `ProjectTemplate`'s own files and revisions, not the target repo's. -4. If in doubt whether an entry is still valid for the current tree, treat it per the guard skill - below rather than guessing. - -This is the same failure class [`carried-instruction-file-guard`](../carried-instruction-file-guard/SKILL.md) -exists to stop for `AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, and `WORKFLOW.md`: a -routine-sounding overwrite silently deleting content that is not a stale copy of the hub. Run -that skill's distinctive-phrase probe against this file too before any full-file replace. It is -not in that skill's own file list because its failure mode, ledger entries rather than fleet -rules, is specific enough to warrant its own skill, but the underlying discipline, probe before -overwrite, give a local addition a destination rather than deleting it, is the same. - -## Checking a repo's copy for drift - -1. Fetch the hub (`github.com/ptr727/ProjectTemplate`) `main` branch fresh. A stale local clone - answers confidently instead of failing. -2. Compare the target repo's `.github/copilot-instructions.md` against the hub's, section by - section, at **intent** fidelity, judged by meaning, not by byte match. A content-identical - file with different `<owner>`/`<repo>` placeholder fills is current, not drifted. -3. Read the "Disproved Claims" section separately from the rest. Judge its **shape and rules** - against the hub, and judge its **entries** only against what that repo itself carries (see - above), never against the hub's own entries. -4. Report what is actually stale (a runbook mechanic that changed, a rule that moved, a new - section) versus what only looks different because it is correctly repo-specific. - -## Carrying it fresh, new repo or full resync - -Follow `RESYNC.md`'s general apply order for carried files, with the ledger rule above applied at -the point this file is touched: carry the hub's current rule text and runbook mechanics, keep the -target repo's own "Disproved Claims" entries (if any existed pre-resync) rather than replacing -them with the hub's, and start a new repo's ledger empty rather than seeded from the hub's own -proofs. - -## What this skill does not cover - -Content-style rules for other carried files (`AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, -`WORKFLOW.md`) are `carried-instruction-file-guard`'s job. The review-loop contract this file's -runbook implements, the merge gate, triage, escalation, is `pr-review-conduct`'s job. This skill -is narrowly about keeping this one file's carried copy correct. +--- +name: copilot-instructions-keeper +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. +--- + +# Copilot Instructions Keeper + +## Why this exists + +`.github/copilot-instructions.md` is read directly by the GitHub Copilot review bot, which +cannot read a `SKILL.md`. Unlike other carried governance content, its rule text is never +pointer-ized into a skill, this file always stays fully intact in every repo that carries it. +This skill is about maintaining that file's carried copy, not a substitute for it. + +`spec/files.json` declares it `intent` fidelity, `whole: true`, covering three named sections +(`Commit Messages and Pull Request Titles`, `Reviewing Carried Fleet Content`, `GitHub Copilot +Review Runbook`), with `<owner>`, `<repo>`, and `<N>` placeholders filled per repo. **The fleet +audit checks an `intent` file for file presence and each named section's heading, never for +content drift inside a section.** A section that is present but has fallen out of date against +the hub, the exact gap this skill exists to catch, produces no finding anywhere in the mechanical +audit. Noticing that has to happen in a live session like this one. + +## The one thing this file has that others don't: repo-local ledger entries + +The file's own "Disproved Claims" section states its rule plainly. **The section's shape and +governing rules are carried, but its entries are not.** Each entry records a finding that was +raised against this specific repository and disproved against this repository's code at a named +revision. A repository carrying a copy of this file carries the shape and rules, deletes any +entry whose subject it does not hold, and records what it has proved for itself. + +This means a blind re-vendor of the hub's canonical `.github/copilot-instructions.md` over a downstream +repo's copy is wrong in both directions: + +- Copying the hub's own "Disproved Claims" entries (about `ProjectTemplate` itself) into a + downstream repo attaches proofs about code that repo does not carry. +- Overwriting a downstream repo's copy wholesale deletes any entries that repo itself has earned, + a live disproof, run against that repo's own tree, thrown away with no record. + +**Before touching this file in any repo other than the hub itself:** + +1. Read the current "Disproved Claims" section in that repo's copy, if it has one, and preserve + every entry that names a file or behavior that repo actually carries. +2. Update everything else, the runbook mechanics, the three named sections, the rule text, to + match the hub canonical. +3. Never carry the hub's own repo-specific "Disproved Claims" entries downstream. They name + `ProjectTemplate`'s own files and revisions, not the target repo's. +4. If in doubt whether an entry is still valid for the current tree, treat it per the guard skill + below rather than guessing. + +This is the same failure class [`carried-instruction-file-guard`](../carried-instruction-file-guard/SKILL.md) +exists to stop for `AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, and `WORKFLOW.md`: a +routine-sounding overwrite silently deleting content that is not a stale copy of the hub. Run +that skill's distinctive-phrase probe against this file too before any full-file replace. It is +not in that skill's own file list because its failure mode, ledger entries rather than fleet +rules, is specific enough to warrant its own skill, but the underlying discipline, probe before +overwrite, give a local addition a destination rather than deleting it, is the same. + +## Checking a repo's copy for drift + +1. Fetch the hub (`github.com/ptr727/ProjectTemplate`) `main` branch fresh. A stale local clone + answers confidently instead of failing. +2. Compare the target repo's `.github/copilot-instructions.md` against the hub's, section by + section, at **intent** fidelity, judged by meaning, not by byte match. A content-identical + file with different `<owner>`/`<repo>` placeholder fills is current, not drifted. +3. Read the "Disproved Claims" section separately from the rest. Judge its **shape and rules** + against the hub, and judge its **entries** only against what that repo itself carries (see + above), never against the hub's own entries. +4. Report what is actually stale (a runbook mechanic that changed, a rule that moved, a new + section) versus what only looks different because it is correctly repo-specific. + +## Carrying it fresh, new repo or full resync + +Follow `RESYNC.md`'s general apply order for carried files, with the ledger rule above applied at +the point this file is touched: carry the hub's current rule text and runbook mechanics, keep the +target repo's own "Disproved Claims" entries (if any existed pre-resync) rather than replacing +them with the hub's, and start a new repo's ledger empty rather than seeded from the hub's own +proofs. + +## What this skill does not cover + +Content-style rules for other carried files (`AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, +`WORKFLOW.md`) are `carried-instruction-file-guard`'s job. The review-loop contract this file's +runbook implements, the merge gate, triage, escalation, is `pr-review-conduct`'s job. This skill +is narrowly about keeping this one file's carried copy correct. diff --git a/.claude-plugin/fleet-skills/skills/dotnet-codestyle/SKILL.md b/.claude-plugin/fleet-skills/skills/dotnet-codestyle/SKILL.md index 8cc485fc..69efa644 100644 --- a/.claude-plugin/fleet-skills/skills/dotnet-codestyle/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/dotnet-codestyle/SKILL.md @@ -1,359 +1,359 @@ ---- -name: dotnet-codestyle -description: >- - Governs C#/.NET code style for ptr727/ProjectTemplate fleet repos: 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. Use this whenever writing, reviewing, or editing a .cs file, a .csproj, - Directory.Build.props, or Directory.Packages.props, whenever choosing where to suppress an - analyzer diagnostic, whenever a NuGet library needs to log without depending on Serilog - directly, or whenever writing or reviewing an xUnit test. Triggers even when the task looks - like a small local fix ("just silence this warning", "add a quick log line", "bump a package - version"), because the zero-warnings policy, the suppression-scope order, the central-package- - management rule, and the library/application logging split are each easy to violate one file at - a time without the pattern ever showing up as a single obvious diff. Applies only to a repo's - .NET side, a repo with no .NET projects has no use for this Skill. ---- - -# .NET Codestyle - -## Why this exists - -This is the .NET-specific half of the fleet's code style guide, kept in one place instead of -re-derived per repo or per session. CODESTYLE.md's General section still owns the rules every -language shares (clean-compile verification as a concept, the suppression-scope order, tooling -casing in prose), this Skill is everything specific to a C#/.NET project on top of that: the -concrete `.NET Format` task chain, the analyzer configuration that makes the zero-warnings policy -real, and the language, naming, logging, and testing conventions. - -## Build requirements - -### Zero warnings policy - -All builds must complete without warnings, enforced three ways: - -- **The `.NET Format` clean-compile task.** It chains `CSharpier Format` -> `.NET Build` -> - `dotnet format style --verify-no-changes`. A repo carries those three task definitions in its - own `.vscode/tasks.json`, matching the canonical `vscode-tasks.json` snippet at - `github.com/ptr727/ProjectTemplate/blob/main/catalog/snippets/configs/vscode-tasks.json`. Run - the `.NET Format` task after any code change, before commit. To run it natively instead, - reproduce that exact task chain (`CSharpier Format`, then `.NET Build`, then - `dotnet format style --verify-no-changes --severity=info --verbosity=detailed`) without dropping - or loosening any argument, reading it from that same canonical snippet. Bare `dotnet format` - alone, skipping CSharpier or the build, is not sufficient. -- **Analyzer configuration.** `<EnableNETAnalyzers>true</EnableNETAnalyzers>` with - `<AnalysisLevel>latest-all</AnalysisLevel>` and `<AnalysisMode>All</AnalysisMode>` (the full - analyzer set), plus `<TreatWarningsAsErrors>true</TreatWarningsAsErrors>`, so any diagnostic - surfaced as a warning fails the build and must be fixed or deliberately suppressed at the - narrowest scope that fits (see Analyzer suppressions below), never left to accumulate. -- **CI lint backstop.** CI runs the clean-compile checks on every PR as the authoritative gate. - Git hooks are optional, and a repo may wire a local runner (Husky.Net, with `dotnet husky run` - as a style step) for pre-commit enforcement, but CI is the gate that matters. - -**A new port is not a license to silence diagnostics.** Brownfield or just-ported status never -justifies relaxing analyzer severities or muting newly surfaced warnings. Fix them. (The only -brownfield allowance in the fleet is the one-time git-signing / line-ending migration described in -GOVERNANCE.md and README.md, which has nothing to do with code analysis.) - -### Central build and package configuration - -Shared MSBuild configuration is centralized at the repository root, never duplicated per project: - -- **`Directory.Build.props`** carries the properties every project shares: the analyzer set and - `TreatWarningsAsErrors` from the zero-warnings policy above, plus `LangVersion`, - `TargetFramework` where uniform, and any repo-wide build metadata. A `.csproj` carries only what - is genuinely project-specific (`OutputType`, `IsPackable`, project references). -- **`Directory.Packages.props`** owns central package management: it sets - `ManagePackageVersionsCentrally` to `true` (in this file, not `Directory.Build.props`) and - declares every dependency version once as a `PackageVersion` item, so a `.csproj`'s - `PackageReference` items are versionless. One file to review on a bump, one Dependabot surface, - no version skew between projects. - -A repo whose projects still carry per-project analyzer settings or versioned `PackageReference` -items is drifted, move the shared property or version up to the root file rather than editing it -in place. - -### Build tasks - -Run these from VS Code's task runner (Terminal -> Run Task) or an agent's task-running tool. The -three clean-compile tasks are carried verbatim, and a repo adds its own convenience tasks (tool -updates, dependency upgrades, benchmarks) on top: - -- `.NET Build`: build with diagnostic verbosity *(clean-compile)* -- `CSharpier Format`: auto-format code with CSharpier *(clean-compile)* -- `.NET Format`: run CSharpier and build, then verify formatting and style with - `--verify-no-changes` *(clean-compile, the task to run after edits)* - -## Tooling and editor - -- **CSharpier** is the primary code formatter, invoked by the `CSharpier Format` task or - `dotnet csharpier format --log-level=debug .`. -- **`dotnet format`** verifies style: - `dotnet format style --verify-no-changes --severity=info --verbosity=detailed`. -- **`dotnet-outdated-tool`** checks for dependency updates, and Nerdbank.GitVersioning owns - version management. -- CI is the authoritative lint backstop. Local pre-commit hooks are optional, wire Husky.Net (or - another runner) if you want local enforcement. -- **Required VS Code extensions**: CSharpier, markdownlint, CSpell. Use the workspace settings - without overrides. - -## Coding standards and conventions - -Code snippets below are illustrative examples only, replace namespaces and types to match your -project. - -### C# language features - -1. **File-scoped namespaces**: - - ```csharp - namespace Example.Project.Library; - ``` - -2. **Nullable reference types**: enabled (`<Nullable>enable</Nullable>`), use nullable annotations - appropriately, use `required` for mandatory properties. -3. **Modern C# features**: prefer modern language constructs, primary constructors when - appropriate, top-level statements for console apps, pattern matching over traditional checks, - collection expressions when types loosely match, extension methods (the classic - `this`-parameter form or an `extension(<receiver>) { ... }` block on C# 14+), implicit object - creation when the type is apparent, range and index operators. -4. **Expression-bodied members**: use for applicable methods, properties, accessors, operators, - lambdas, local functions. -5. **`var` keyword**: do NOT use `var`, always use explicit types: - - ```csharp - // Correct - int count = 42; - string name = "test"; - - // Incorrect - var count = 42; - var name = "test"; - ``` - -### Naming conventions - -1. **Private fields**: underscore prefix with camelCase: - - ```csharp - private readonly HttpClient _httpClient; - private int _counter; - ``` - -2. **Static fields**: `s_` prefix with camelCase: - - ```csharp - private static int s_instanceCount; - ``` - -3. **Constants**: PascalCase: - - ```csharp - private const int MaxRetries = 3; - ``` - -### Code structure - -1. **Global usings**: use `GlobalUsings.cs` for common namespaces: - - ```csharp - global using System; - global using System.Net.Http; - global using System.Threading.Tasks; - global using Microsoft.Extensions.Logging; - ``` - -2. **Usings placement**: outside the namespace, sorted with `System` directives first: - - ```csharp - using System.CommandLine; - using System.Runtime.CompilerServices; - using Example.Project.Library; - - namespace Example.Project.Console; - ``` - -3. **Braces**: Allman style: - - ```csharp - public void Method() - { - if (condition) - { - // code - } - } - ``` - -4. **Indentation**: C# files 4 spaces, XML/csproj files 2 spaces, YAML files 2 spaces, JSON files - 4 spaces. -5. **Line endings**: not specified here, governed per repo by `.editorconfig` / `.gitattributes` - per GOVERNANCE.md's "Line Endings" section. -6. **`#region`**: do not use regions, prefer logical file/folder/namespace organization. -7. **Member ordering (StyleCop SA1201)**: const -> static readonly -> static fields -> instance - readonly fields -> instance fields -> constructors -> public (events -> properties -> indexers - -> methods -> operators) -> non-public in same order -> nested types. - -### Comments and documentation - -XML documentation is on: `<GenerateDocumentationFile>true</GenerateDocumentationFile>`, and -missing XML comments for public APIs are suppressed in `.editorconfig`. Every public surface must -still be documented: a single-line summary, additional details in remarks, documented input -parameters, return values, exceptions, and crefs. - -```csharp -/// <summary> -/// Example of a single line summary. -/// </summary> -/// <remarks> -/// Additional important details about usage. -/// Multiple lines if needed. -/// </remarks> -/// <param name="category"> -/// The quote category to request -/// </param> -/// <param name="cancellationToken"> -/// A <see cref="System.Threading.CancellationToken"/> that can be used to cancel the request. -/// </param> -/// <returns> -/// A <see cref="string"/> containing the quote text. -/// </returns> -/// <exception cref="System.ArgumentException"> -/// Thrown when <paramref name="category"/> is not a supported value. -/// </exception> -public async Task<string> GetQuoteOfTheDayAsync(string category, CancellationToken cancellationToken) {} -``` - -## Analyzer suppressions (.NET) - -CODESTYLE.md's General section sets the suppression-scope order fleet-wide: narrowest scope first, -symbol-scoped before project-scoped before repo-wide, and only for a genuine false-positive or a -deliberate, documented exception, never a blanket relaxation to get a brownfield port to build. -The .NET mechanics, narrowest first: - -- **Never use `#pragma warning disable`** to silence an analyzer. -- **Symbol-scoped**: a `[System.Diagnostics.CodeAnalysis.SuppressMessage(...)]` attribute with a - `Justification`, on the specific member or type: - - ```csharp - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Design", - "CA1034:Nested types should not be visible", - Justification = "https://github.com/dotnet/sdk/issues/51681" - )] - ``` - -- **Project-scoped** (e.g. a test project): a `dotnet_diagnostic.<RULE>.severity` entry in that - project's own `.editorconfig`, with a comment explaining why. -- **Repo-wide**: a `dotnet_diagnostic.<RULE>.severity` entry in the root `.editorconfig`, only - when the rule is genuinely not applicable to any project. Relaxing a batch of `CA*` rules (or - `dotnet_analyzer_diagnostic.severity`) to push a brownfield port through the build is exactly - what this forbids. - -## Error handling and logging - -1. **Structured logging**: use structured message templates. Serilog is the application's concrete - backend, and a library never references it directly (see item 2): - - ```csharp - logger.LogError(exception, "{Function}", function); - ``` - -2. **Libraries log through abstractions, never a concrete backend.** A NuGet library depends only - on `Microsoft.Extensions.Logging.Abstractions` and exposes an `ILoggerFactory` seam: a settable - global factory defaulting to `NullLoggerFactory.Instance` (fallback `NullLogger.Instance`) with - `SetFactory`/`TrySetFactory`, and/or an `ILoggerFactory`/`ILogger` parameter in its API. It - must not reference Serilog or any sink, which would force a logging framework on every consumer - and drag in AOT-incompatible dependencies. The consuming application owns the concrete logger - (Serilog is fine there), bridges it to `ILoggerFactory` (e.g. `SerilogLoggerFactory` from - `Serilog.Extensions.Logging`), and injects it. Reference pattern: a `LogOptions` seam in the - library, against which the consuming CLI builds the Serilog-backed factory and injects it via - `LogOptions.SetFactory`. -3. **CallerMemberName**: use for automatic function name tracking: - - ```csharp - public bool LogAndPropagate( - Exception exception, - [CallerMemberName] string function = "unknown" - ) - ``` - -4. **Logger extensions**: use `Extensions.cs` for logger and other extension methods: - - ```csharp - extension(ILogger logger) - { - public bool LogAndPropagate(Exception exception, ...) { } - } - ``` - -5. **Exceptions**: do not swallow exceptions, either log and rethrow or translate to a - domain-specific exception. - -## Code patterns - -1. **Guard clauses**: prefer early returns for validation and error handling. -2. **Async all the way**: avoid blocking calls (`.Result`, `.Wait()`), use `async`/`await`. -3. **Cancellation tokens**: accept `CancellationToken` as the last parameter and pass it through. -4. **ConfigureAwait**: in library code, use `ConfigureAwait(false)` unless context is required. Do - not call `ConfigureAwait(false)` in xUnit tests (see xUnit1030). -5. **Disposables**: use `await using` for async disposables, prefer `using` declarations. -6. **LINQ vs loops**: use LINQ for clarity, loops for hot paths or allocations. -7. **HTTP**: reuse `HttpClient` via factory, never per-request instantiation. -8. **Collections**: prefer `IReadOnlyList<T>`/`IReadOnlyCollection<T>` for public APIs. -9. **Immutability**: prefer immutable records, use init-only setters when records are not - suitable, and prefer immutable or frozen collections for read-only data. -10. **Exceptions as control flow**: avoid using exceptions for expected flow. -11. **Sealing classes**: seal classes that are not designed for inheritance. -12. **Lazy initialization**: use `Lazy<T>` for static, thread-safe instantiation (e.g. a logger - factory, an HTTP factory). - -## Testing conventions - -1. **Framework**: xUnit v3 or later (the `xunit.v3` package, never the legacy v2 `xunit` package) - with AwesomeAssertions for every assertion. Native xUnit asserts (`Assert.Equal`, - `Assert.True`, ...) are not allowed, use the fluent `.Should()` API. Dynamic test skipping - (`Assert.Skip`, `Assert.SkipWhen`) is control flow, not an assertion, and stays native: - - ```csharp - [Fact] - public void MethodName_Scenario_ExpectedBehavior() - { - // Arrange - int expected = 42; - - // Act - int actual = GetValue(); - - // Assert - actual.Should().Be(expected); - } - ``` - -2. **Organization**: Arrange-Act-Assert pattern. -3. **Naming**: descriptive names with underscores. -4. **Theory tests**: use `[Theory]` with `[InlineData]`. - -## Project configuration - -1. **Target framework**: .NET 10.0 (`<TargetFramework>net10.0</TargetFramework>`). -2. **AOT compatibility**: `<IsAotCompatible>true</IsAotCompatible>`, - `<VerifyReferenceAotCompatibility>true</VerifyReferenceAotCompatibility>`. -3. **Assembly information**: use semantic versioning, include SourceLink - (`<PublishRepositoryUrl>true</PublishRepositoryUrl>`), embed untracked sources - (`<EmbedUntrackedSources>true</EmbedUntrackedSources>`). -4. **Internal visibility**: use `InternalsVisibleTo` for test and benchmark access (adapt the - project names to your repo's test/benchmark projects): - - ```xml - <ItemGroup> - <InternalsVisibleTo Include="YourBenchmarkProject" /> - <InternalsVisibleTo Include="YourTestProject" /> - </ItemGroup> - ``` - -## Best practices - -All changes go through pull requests. +--- +name: dotnet-codestyle +description: >- + Governs C#/.NET code style for ptr727/ProjectTemplate fleet repos: 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. Use this whenever writing, reviewing, or editing a .cs file, a .csproj, + Directory.Build.props, or Directory.Packages.props, whenever choosing where to suppress an + analyzer diagnostic, whenever a NuGet library needs to log without depending on Serilog + directly, or whenever writing or reviewing an xUnit test. Triggers even when the task looks + like a small local fix ("just silence this warning", "add a quick log line", "bump a package + version"), because the zero-warnings policy, the suppression-scope order, the central-package- + management rule, and the library/application logging split are each easy to violate one file at + a time without the pattern ever showing up as a single obvious diff. Applies only to a repo's + .NET side, a repo with no .NET projects has no use for this Skill. +--- + +# .NET Codestyle + +## Why this exists + +This is the .NET-specific half of the fleet's code style guide, kept in one place instead of +re-derived per repo or per session. CODESTYLE.md's General section still owns the rules every +language shares (clean-compile verification as a concept, the suppression-scope order, tooling +casing in prose), this Skill is everything specific to a C#/.NET project on top of that: the +concrete `.NET Format` task chain, the analyzer configuration that makes the zero-warnings policy +real, and the language, naming, logging, and testing conventions. + +## Build requirements + +### Zero warnings policy + +All builds must complete without warnings, enforced three ways: + +- **The `.NET Format` clean-compile task.** It chains `CSharpier Format` -> `.NET Build` -> + `dotnet format style --verify-no-changes`. A repo carries those three task definitions in its + own `.vscode/tasks.json`, matching the canonical `vscode-tasks.json` snippet at + `github.com/ptr727/ProjectTemplate/blob/main/catalog/snippets/configs/vscode-tasks.json`. Run + the `.NET Format` task after any code change, before commit. To run it natively instead, + reproduce that exact task chain (`CSharpier Format`, then `.NET Build`, then + `dotnet format style --verify-no-changes --severity=info --verbosity=detailed`) without dropping + or loosening any argument, reading it from that same canonical snippet. Bare `dotnet format` + alone, skipping CSharpier or the build, is not sufficient. +- **Analyzer configuration.** `<EnableNETAnalyzers>true</EnableNETAnalyzers>` with + `<AnalysisLevel>latest-all</AnalysisLevel>` and `<AnalysisMode>All</AnalysisMode>` (the full + analyzer set), plus `<TreatWarningsAsErrors>true</TreatWarningsAsErrors>`, so any diagnostic + surfaced as a warning fails the build and must be fixed or deliberately suppressed at the + narrowest scope that fits (see Analyzer suppressions below), never left to accumulate. +- **CI lint backstop.** CI runs the clean-compile checks on every PR as the authoritative gate. + Git hooks are optional, and a repo may wire a local runner (Husky.Net, with `dotnet husky run` + as a style step) for pre-commit enforcement, but CI is the gate that matters. + +**A new port is not a license to silence diagnostics.** Brownfield or just-ported status never +justifies relaxing analyzer severities or muting newly surfaced warnings. Fix them. (The only +brownfield allowance in the fleet is the one-time git-signing / line-ending migration described in +GOVERNANCE.md and README.md, which has nothing to do with code analysis.) + +### Central build and package configuration + +Shared MSBuild configuration is centralized at the repository root, never duplicated per project: + +- **`Directory.Build.props`** carries the properties every project shares: the analyzer set and + `TreatWarningsAsErrors` from the zero-warnings policy above, plus `LangVersion`, + `TargetFramework` where uniform, and any repo-wide build metadata. A `.csproj` carries only what + is genuinely project-specific (`OutputType`, `IsPackable`, project references). +- **`Directory.Packages.props`** owns central package management: it sets + `ManagePackageVersionsCentrally` to `true` (in this file, not `Directory.Build.props`) and + declares every dependency version once as a `PackageVersion` item, so a `.csproj`'s + `PackageReference` items are versionless. One file to review on a bump, one Dependabot surface, + no version skew between projects. + +A repo whose projects still carry per-project analyzer settings or versioned `PackageReference` +items is drifted, move the shared property or version up to the root file rather than editing it +in place. + +### Build tasks + +Run these from VS Code's task runner (Terminal -> Run Task) or an agent's task-running tool. The +three clean-compile tasks are carried verbatim, and a repo adds its own convenience tasks (tool +updates, dependency upgrades, benchmarks) on top: + +- `.NET Build`: build with diagnostic verbosity *(clean-compile)* +- `CSharpier Format`: auto-format code with CSharpier *(clean-compile)* +- `.NET Format`: run CSharpier and build, then verify formatting and style with + `--verify-no-changes` *(clean-compile, the task to run after edits)* + +## Tooling and editor + +- **CSharpier** is the primary code formatter, invoked by the `CSharpier Format` task or + `dotnet csharpier format --log-level=debug .`. +- **`dotnet format`** verifies style: + `dotnet format style --verify-no-changes --severity=info --verbosity=detailed`. +- **`dotnet-outdated-tool`** checks for dependency updates, and Nerdbank.GitVersioning owns + version management. +- CI is the authoritative lint backstop. Local pre-commit hooks are optional, wire Husky.Net (or + another runner) if you want local enforcement. +- **Required VS Code extensions**: CSharpier, markdownlint, CSpell. Use the workspace settings + without overrides. + +## Coding standards and conventions + +Code snippets below are illustrative examples only, replace namespaces and types to match your +project. + +### C# language features + +1. **File-scoped namespaces**: + + ```csharp + namespace Example.Project.Library; + ``` + +2. **Nullable reference types**: enabled (`<Nullable>enable</Nullable>`), use nullable annotations + appropriately, use `required` for mandatory properties. +3. **Modern C# features**: prefer modern language constructs, primary constructors when + appropriate, top-level statements for console apps, pattern matching over traditional checks, + collection expressions when types loosely match, extension methods (the classic + `this`-parameter form or an `extension(<receiver>) { ... }` block on C# 14+), implicit object + creation when the type is apparent, range and index operators. +4. **Expression-bodied members**: use for applicable methods, properties, accessors, operators, + lambdas, local functions. +5. **`var` keyword**: do NOT use `var`, always use explicit types: + + ```csharp + // Correct + int count = 42; + string name = "test"; + + // Incorrect + var count = 42; + var name = "test"; + ``` + +### Naming conventions + +1. **Private fields**: underscore prefix with camelCase: + + ```csharp + private readonly HttpClient _httpClient; + private int _counter; + ``` + +2. **Static fields**: `s_` prefix with camelCase: + + ```csharp + private static int s_instanceCount; + ``` + +3. **Constants**: PascalCase: + + ```csharp + private const int MaxRetries = 3; + ``` + +### Code structure + +1. **Global usings**: use `GlobalUsings.cs` for common namespaces: + + ```csharp + global using System; + global using System.Net.Http; + global using System.Threading.Tasks; + global using Microsoft.Extensions.Logging; + ``` + +2. **Usings placement**: outside the namespace, sorted with `System` directives first: + + ```csharp + using System.CommandLine; + using System.Runtime.CompilerServices; + using Example.Project.Library; + + namespace Example.Project.Console; + ``` + +3. **Braces**: Allman style: + + ```csharp + public void Method() + { + if (condition) + { + // code + } + } + ``` + +4. **Indentation**: C# files 4 spaces, XML/csproj files 2 spaces, YAML files 2 spaces, JSON files + 4 spaces. +5. **Line endings**: not specified here, governed per repo by `.editorconfig` / `.gitattributes` + per GOVERNANCE.md's "Line Endings" section. +6. **`#region`**: do not use regions, prefer logical file/folder/namespace organization. +7. **Member ordering (StyleCop SA1201)**: const -> static readonly -> static fields -> instance + readonly fields -> instance fields -> constructors -> public (events -> properties -> indexers + -> methods -> operators) -> non-public in same order -> nested types. + +### Comments and documentation + +XML documentation is on: `<GenerateDocumentationFile>true</GenerateDocumentationFile>`, and +missing XML comments for public APIs are suppressed in `.editorconfig`. Every public surface must +still be documented: a single-line summary, additional details in remarks, documented input +parameters, return values, exceptions, and crefs. + +```csharp +/// <summary> +/// Example of a single line summary. +/// </summary> +/// <remarks> +/// Additional important details about usage. +/// Multiple lines if needed. +/// </remarks> +/// <param name="category"> +/// The quote category to request +/// </param> +/// <param name="cancellationToken"> +/// A <see cref="System.Threading.CancellationToken"/> that can be used to cancel the request. +/// </param> +/// <returns> +/// A <see cref="string"/> containing the quote text. +/// </returns> +/// <exception cref="System.ArgumentException"> +/// Thrown when <paramref name="category"/> is not a supported value. +/// </exception> +public async Task<string> GetQuoteOfTheDayAsync(string category, CancellationToken cancellationToken) {} +``` + +## Analyzer suppressions (.NET) + +CODESTYLE.md's General section sets the suppression-scope order fleet-wide: narrowest scope first, +symbol-scoped before project-scoped before repo-wide, and only for a genuine false-positive or a +deliberate, documented exception, never a blanket relaxation to get a brownfield port to build. +The .NET mechanics, narrowest first: + +- **Never use `#pragma warning disable`** to silence an analyzer. +- **Symbol-scoped**: a `[System.Diagnostics.CodeAnalysis.SuppressMessage(...)]` attribute with a + `Justification`, on the specific member or type: + + ```csharp + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Design", + "CA1034:Nested types should not be visible", + Justification = "https://github.com/dotnet/sdk/issues/51681" + )] + ``` + +- **Project-scoped** (e.g. a test project): a `dotnet_diagnostic.<RULE>.severity` entry in that + project's own `.editorconfig`, with a comment explaining why. +- **Repo-wide**: a `dotnet_diagnostic.<RULE>.severity` entry in the root `.editorconfig`, only + when the rule is genuinely not applicable to any project. Relaxing a batch of `CA*` rules (or + `dotnet_analyzer_diagnostic.severity`) to push a brownfield port through the build is exactly + what this forbids. + +## Error handling and logging + +1. **Structured logging**: use structured message templates. Serilog is the application's concrete + backend, and a library never references it directly (see item 2): + + ```csharp + logger.LogError(exception, "{Function}", function); + ``` + +2. **Libraries log through abstractions, never a concrete backend.** A NuGet library depends only + on `Microsoft.Extensions.Logging.Abstractions` and exposes an `ILoggerFactory` seam: a settable + global factory defaulting to `NullLoggerFactory.Instance` (fallback `NullLogger.Instance`) with + `SetFactory`/`TrySetFactory`, and/or an `ILoggerFactory`/`ILogger` parameter in its API. It + must not reference Serilog or any sink, which would force a logging framework on every consumer + and drag in AOT-incompatible dependencies. The consuming application owns the concrete logger + (Serilog is fine there), bridges it to `ILoggerFactory` (e.g. `SerilogLoggerFactory` from + `Serilog.Extensions.Logging`), and injects it. Reference pattern: a `LogOptions` seam in the + library, against which the consuming CLI builds the Serilog-backed factory and injects it via + `LogOptions.SetFactory`. +3. **CallerMemberName**: use for automatic function name tracking: + + ```csharp + public bool LogAndPropagate( + Exception exception, + [CallerMemberName] string function = "unknown" + ) + ``` + +4. **Logger extensions**: use `Extensions.cs` for logger and other extension methods: + + ```csharp + extension(ILogger logger) + { + public bool LogAndPropagate(Exception exception, ...) { } + } + ``` + +5. **Exceptions**: do not swallow exceptions, either log and rethrow or translate to a + domain-specific exception. + +## Code patterns + +1. **Guard clauses**: prefer early returns for validation and error handling. +2. **Async all the way**: avoid blocking calls (`.Result`, `.Wait()`), use `async`/`await`. +3. **Cancellation tokens**: accept `CancellationToken` as the last parameter and pass it through. +4. **ConfigureAwait**: in library code, use `ConfigureAwait(false)` unless context is required. Do + not call `ConfigureAwait(false)` in xUnit tests (see xUnit1030). +5. **Disposables**: use `await using` for async disposables, prefer `using` declarations. +6. **LINQ vs loops**: use LINQ for clarity, loops for hot paths or allocations. +7. **HTTP**: reuse `HttpClient` via factory, never per-request instantiation. +8. **Collections**: prefer `IReadOnlyList<T>`/`IReadOnlyCollection<T>` for public APIs. +9. **Immutability**: prefer immutable records, use init-only setters when records are not + suitable, and prefer immutable or frozen collections for read-only data. +10. **Exceptions as control flow**: avoid using exceptions for expected flow. +11. **Sealing classes**: seal classes that are not designed for inheritance. +12. **Lazy initialization**: use `Lazy<T>` for static, thread-safe instantiation (e.g. a logger + factory, an HTTP factory). + +## Testing conventions + +1. **Framework**: xUnit v3 or later (the `xunit.v3` package, never the legacy v2 `xunit` package) + with AwesomeAssertions for every assertion. Native xUnit asserts (`Assert.Equal`, + `Assert.True`, ...) are not allowed, use the fluent `.Should()` API. Dynamic test skipping + (`Assert.Skip`, `Assert.SkipWhen`) is control flow, not an assertion, and stays native: + + ```csharp + [Fact] + public void MethodName_Scenario_ExpectedBehavior() + { + // Arrange + int expected = 42; + + // Act + int actual = GetValue(); + + // Assert + actual.Should().Be(expected); + } + ``` + +2. **Organization**: Arrange-Act-Assert pattern. +3. **Naming**: descriptive names with underscores. +4. **Theory tests**: use `[Theory]` with `[InlineData]`. + +## Project configuration + +1. **Target framework**: .NET 10.0 (`<TargetFramework>net10.0</TargetFramework>`). +2. **AOT compatibility**: `<IsAotCompatible>true</IsAotCompatible>`, + `<VerifyReferenceAotCompatibility>true</VerifyReferenceAotCompatibility>`. +3. **Assembly information**: use semantic versioning, include SourceLink + (`<PublishRepositoryUrl>true</PublishRepositoryUrl>`), embed untracked sources + (`<EmbedUntrackedSources>true</EmbedUntrackedSources>`). +4. **Internal visibility**: use `InternalsVisibleTo` for test and benchmark access (adapt the + project names to your repo's test/benchmark projects): + + ```xml + <ItemGroup> + <InternalsVisibleTo Include="YourBenchmarkProject" /> + <InternalsVisibleTo Include="YourTestProject" /> + </ItemGroup> + ``` + +## Best practices + +All changes go through pull requests. diff --git a/.claude-plugin/fleet-skills/skills/fleet-conformance-check/SKILL.md b/.claude-plugin/fleet-skills/skills/fleet-conformance-check/SKILL.md index 923c288c..2b6e8406 100644 --- a/.claude-plugin/fleet-skills/skills/fleet-conformance-check/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/fleet-conformance-check/SKILL.md @@ -1,74 +1,74 @@ ---- -name: fleet-conformance-check -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 - whenever asked to check if this repo is up to date with the hub, whenever a fleet rule or Skill - seems to not be applying and the cause is unclear, or whenever about to work in a fleet repo and - wanting to confirm the ground under that work is current before trusting it. Needs no standing - hub checkout of its own and no named target repo, only the repo the session is already in, - though the check itself fetches a hub checkout to reach scripts/skills_install.py, since - scripts/ is hub-hosted rather than carried. This is the counterpart to resync-a-repo, which - needs both a hub checkout already in hand and a named external target to drive change from the - hub side instead. Also triggers on "why do I have to keep restating this rule every session," - since a stale or missing Skills install is the most common cause and the cheapest one to rule - out first. ---- - -# Fleet Conformance Check - -## 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, -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. - -## What it checks - -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 - 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. -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 - than by feel. - -## What it is safe to fix on its own - -- **Re-run the installer**, `python3 scripts/skills_install.py`, when the stamp reports stale. - 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. - -## 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, -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. - -## What it escalates instead of touching - -- **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. -- **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. - -## Answering "why isn't a fleet rule applying" - -Check the install stamp first, before assuming a Skill's description is worded wrong or that the -rule was never carried to this repo at all. It is the most common cause, and it is the cheapest -one to confirm. +--- +name: fleet-conformance-check +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 + whenever asked to check if this repo is up to date with the hub, whenever a fleet rule or Skill + seems to not be applying and the cause is unclear, or whenever about to work in a fleet repo and + wanting to confirm the ground under that work is current before trusting it. Needs no standing + hub checkout of its own and no named target repo, only the repo the session is already in, + though the check itself fetches a hub checkout to reach scripts/skills_install.py, since + scripts/ is hub-hosted rather than carried. This is the counterpart to resync-a-repo, which + needs both a hub checkout already in hand and a named external target to drive change from the + hub side instead. Also triggers on "why do I have to keep restating this rule every session," + since a stale or missing Skills install is the most common cause and the cheapest one to rule + out first. +--- + +# Fleet Conformance Check + +## 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, +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. + +## What it checks + +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 + 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. +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 + than by feel. + +## What it is safe to fix on its own + +- **Re-run the installer**, `python3 scripts/skills_install.py`, when the stamp reports stale. + 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. + +## 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, +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. + +## What it escalates instead of touching + +- **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. +- **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. + +## Answering "why isn't a fleet rule applying" + +Check the install stamp first, before assuming a Skill's description is worded wrong or that the +rule was never carried to this repo at all. It is the most common cause, and it is the cheapest +one to confirm. diff --git a/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md b/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md index 9790f987..3709d31a 100644 --- a/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md @@ -1,185 +1,184 @@ ---- -name: git-commit-conventions -description: >- - Governs how an agent stages, commits, signs, and pushes in a ptr727/ProjectTemplate fleet repo: - default-to-staging vs. explicit commit authorization, why "commit" means commit-and-push, the - mandatory signed-commit and noreply-identity checks, never force-pushing, how a history rewrite - must re-identify a commit that is not the agent's own, and the destructive-git-command ban. Use - this whenever about to run git add/commit/push, whenever authorization to commit is ambiguous - ("fix this" versus "commit this"), whenever about to configure or verify commit signing or - git user.email, whenever a merge conflict or a stale branch tempts a force-push or a hard reset, - and whenever rewriting history (filter-repo, an interactive rebase equivalent) touches a commit - authored or committed by someone else. Triggers even when the task looks like routine - housekeeping, such as "clean up this branch" or "just push it", because a scope-widened commit - authorization, an unsigned commit, a fabricated identity, or a force-push are each easy to do by - habit and each one is a hard-to-reverse mistake on a shared branch. ---- - -# Git Commit Conventions - -## Why this exists - -These are the fleet's mechanical git rules for producing a commit, kept in one place instead of -re-derived per repo or per session: whether to commit at all, what committing implies, how -signing and identity are verified rather than configured, and which commands are never run -without being asked. None of these are style preferences. Branch protection enforces several of -them at push time, and the rest guard against damage a rejected push does not undo (a -scope-widened commit, a rewritten shared history, a destructive reset). - -## Staging versus committing - -- **Default to staging, not committing.** Stage with `git add` and leave `git commit` to the - developer unless the developer has explicitly authorized committing for the current ask ("commit - this", "open a PR"). Authorization is scope-bound: it covers the commits that specific task - needs, not a blanket license for the rest of the session. -- **Stage by explicit path, never `git add -A` or `git add .`.** A blanket add stages whatever - else happens to be in the tree, and what it sweeps in is another task's uncommitted work, - landing in a commit whose subject never mentions it, committed by a session that never saw it. - That sweep has happened, which is why task isolation exists (the `repo-worktree` skill), and - isolation makes a shared tree rare rather than impossible. Name the files this task changed, - and let anything else stay unstaged. -- **"Commit" means commit and push.** An authorization to commit carries the push to the feature - branch the work belongs on, because nothing reviews a local commit. The Copilot review loop, the - required status checks, and the maintainer all read the remote, so stopping at `git commit` - leaves the review unstarted and the branch's state private to one machine, which reads as - progress while none of the gates have run. Push to the feature branch, never to a protected - branch, and never with `--force`. Holding a commit locally is the narrower case: it happens when - the developer asks for it, not by default. -- **Check `git status` before committing, and treat any change this session did not make as a - stop.** The maintainer hand-edits files live, often `README.md`/`HISTORY.md`, sometimes with an - editor's LF -> CRLF flip on top, and a sibling agent session sharing the tree leaves its edits - the same way. Whoever the author is, a change this session did not make is never bundled: ask - whether to include it, or leave it unstaged and say so, rather than committing half-finished - work or stranding it in an unrelated commit. An unexpected change in the tree is also the - signal to re-check isolation per the `repo-worktree` skill, since it may mean another task is - live in this checkout. - -## Signing, verified not configured - -- **Every commit must be cryptographically signed (SSH or GPG).** Branch protection enforces this - on every fleet branch, and an unsigned commit is rejected on push. Signing depends on - environment configuration (`commit.gpgsign`, `user.signingkey`, `gpg.format`), but none of those - values prove signing actually works: `gpg.format=ssh` can sign straight from a key file with no - `ssh-agent` running at all (the common case on Git for Windows), just as GPG can sign - agent-backed or straight from a keyring. **Probing agent liveness (`ssh-add -L`, a `gpg-agent` - check) is not a valid test and must not be used.** It tests one specific delivery path, not - whether a commit actually ends up signed, and a host that signs straight from a key file fails - that probe while signing correctly. -- **Verify with a real scratch commit, read back with git's own verdict, not a text grep.** This - single probe is tech-agnostic (SSH agent-backed, SSH key-file, GPG agent-backed, and GPG keyring - all exercise the same code path) and doubles as the identity check below. Run it once before the - first agent-authored commit of a session. Don't assume a prior session left config correct. The - commit below is plain, deliberately no `-S`: forcing it would still succeed on a host where - `commit.gpgsign` is unset or false, which is the exact default-config gap this probe exists to - catch, since every real commit an agent makes is plain too: - - This file is CRLF (the repo's Markdown default), and a `\` line continuation stops working - the moment a stray `\r` lands after it, so the probe is one physical line, not backslash-joined - ones: - - ```sh - d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( trap 'rm -rf "$d"' 0; email=$(git config --global --get user.email) && git init -q "$d" && git -C "$d" commit --allow-empty -q -m check && out=$(git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>') && echo "$out" && ae=$(git -C "$d" log -1 --format='%ae') && ce=$(git -C "$d" log -1 --format='%ce') && case "$out" in sig=G\ *|sig=U\ *) true ;; *) false ;; esac && case "$email" in *@users.noreply.github.com) true ;; *) false ;; esac && [ "$ae" = "$email" ] && [ "$ce" = "$email" ] ) - ``` - - PowerShell equivalent: - - ```powershell - $d = Join-Path $env:TEMP ([guid]::NewGuid()) - try { - $email = git config --global --get user.email - git init -q "$d" ` - && git -C "$d" commit --allow-empty -q -m check - $out = git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' - $out - $ae = git -C "$d" log -1 --format='%ae' - $ce = git -C "$d" log -1 --format='%ce' - if ($out -notmatch '^sig=[GU] ' -or $email -notmatch '@users\.noreply\.github\.com$' ` - -or $ae -ne $email -or $ce -ne $email) { - throw "signing/identity check failed: $out" - } - } finally { - if (Test-Path "$d") { Remove-Item -Recurse -Force "$d" } - } - ``` - - `sig` must read `G` (good signature) or `U` (good signature, unrecognized signer). For GPG, `U` - is a valid signature from a key whose trust level is merely undefined, common right after - generating a new key. For SSH, it's a valid signature from a key not found in the local - `allowed_signers` file, which doesn't affect whether GitHub itself verifies the commit, only - local `git verify-commit` output. `sig` is git's own verdict char. Don't grep localized - "Good" text, since that varies by git version and locale. Anything else, or the commit failing - outright, means **do not commit**: surface the actual error to the developer and stop at - `git add`. Nothing else is contrary evidence: not an unreachable agent, not a config value, not a - signature type you can't otherwise explain in past history (see below). -- **A mix of SSH- and GPG-signed commits in history is structural, not a host to track down.** - `git log --pretty='%G? %GK'` shows two distinct shapes, not two health states: a commit committed - by the PR's own author carries that host's own signature type, while a commit committed by - `GitHub <noreply@github.com>` is a squash-merge: GitHub creates and signs that commit itself, - server-side, with GitHub's own GPG key, regardless of what the PR author signed with locally. - Every commit on `develop`/`main` past its first squash-merge shows `GitHub` as committer and a - GPG signature. That's expected on every fleet repo, on every host, and is not evidence anything - is misconfigured. Check `commit.committer.name` before treating a differing signature type as a - clue worth chasing. -- **Signing must be live before the *first* commit, not retrofitted.** Turning on a - require-signed-commits rule against a branch that already carries unsigned commits forces a - rewrite of that entire history to re-sign it, changing every commit SHA and making whoever does - the rewrite the committer and signer of every commit in it (a rebase preserves `author` but not - the original signatures, and one contributor cannot sign for another). During new-repo setup, - never create commits until signing is verified. - -## Identity, verified not set - -**Commit under the committing account's own GitHub `noreply` identity, never a private, personal, -or invented address.** `author` and `committer` on every agent-authored commit are the GitHub -`noreply` address of the account whose key signs the commit, in `username@users.noreply.github.com` -or `ID+username@users.noreply.github.com` form. **Verify it, do not set it**: the scratch commit -from the signing check above already proves this end-to-end. Read its `author=`/`committer=` -output rather than trusting `git config --get user.email` alone, since a global config value -doesn't prove what actually lands on a commit object, and read both rather than the author alone -since a rebase, amend, or cherry-pick can rewrite the committer while leaving the author -untouched. Match both against that address before committing, rather than -writing a repo-local override. The identity is host configuration set globally once, so a repo-local -`user.email` is redundant where the global is right and a silently-shadowing wrong identity where -it is not. A mismatch is a host fault to surface to the maintainer, not to patch per repo, because -a local override hides a broken host that then commits wrong in every other repo on that machine. -A wrong identity is not cosmetic: a private email trips GitHub's email-privacy push protection, and -an invented author pollutes history. It is also a distinct failure from signing (a wrong author -does not by itself fail the signature check), though the ad-hoc identities that produce one are -typically also unsigned, which the signing rule above then rejects independently. - -## Never force push - -Do not run `git push --force` or `git push --force-with-lease` under any circumstances. Force -pushing rewrites shared history and can cause data loss. This holds regardless of how confident -the rewrite looks, a rejected push is recoverable, a force-pushed one is not. - -## History rewrites re-identify only what changed - -**A history rewrite includes only the commits that must change, and re-identifies any commit it -rewrites that is not the agent's own.** Filtering history (`git filter-repo` or an equivalent, for -example to strip PII) re-signs every commit it touches with the rewriter's own key, while the -tooling preserves each commit's original `author`/`committer` unless told otherwise. GitHub -verifies a signature against the commit's `committer` identity, so a signature from the rewriter's -key over a commit still committed by a bot (`dependabot[bot]`, `github-actions[bot]`) or GitHub's -own web-flow does not match its committer and lands `unknown_key`/unverified, which a -require-signed-commits rule then rejects. - -Two gates keep committer and signature aligned: - -1. **Scope the rewrite to only the commits that must be modified.** By default those are the - rewriter's own, whose committer already matches, so a commit that needs no change stays out of - the rewrite entirely and its identity and signature are never touched. -2. **If a commit that must change is not the rewriter's own, set its `committer` to the rewriter's - own signing identity before re-signing** (and its `author` too, since a rewrite that alters - content should not keep attributing it to the bot). The original bot attribution is deliberately - given up as the cost of having to rewrite it. - -Never leave a signature over a commit committed by another identity. Verify after any rewrite that -every rewritten commit is signed and committed under the correct identity -(`git log --show-signature`). - -## Never run destructive git commands without being asked - -`git reset --hard`, `git checkout .`, `git restore .`, `git clean -f`, and anything else that -discards uncommitted work runs only on explicit developer instruction, never as a convenience step -inside a larger task. +--- +name: git-commit-conventions +description: >- + Governs how an agent stages, commits, signs, and pushes in a ptr727/ProjectTemplate fleet repo: + default-to-staging vs. explicit commit authorization, why "commit" means commit-and-push, the + mandatory signed-commit and noreply-identity checks, never force-pushing, how a history rewrite + must re-identify a commit that is not the agent's own, and the destructive-git-command ban. Use + this whenever about to run git add/commit/push, whenever authorization to commit is ambiguous + ("fix this" versus "commit this"), whenever about to configure or verify commit signing or + git user.email, whenever a merge conflict or a stale branch tempts a force-push or a hard reset, + and whenever rewriting history (filter-repo, an interactive rebase equivalent) touches a commit + authored or committed by someone else. Triggers even when the task looks like routine + housekeeping, such as "clean up this branch" or "just push it", because a scope-widened commit + authorization, an unsigned commit, a fabricated identity, or a force-push are each easy to do by + habit and each one is a hard-to-reverse mistake on a shared branch. +--- + +# Git Commit Conventions + +## Why this exists + +These are the fleet's mechanical git rules for producing a commit, kept in one place instead of +re-derived per repo or per session: whether to commit at all, what committing implies, how +signing and identity are verified rather than configured, and which commands are never run +without being asked. None of these are style preferences. Branch protection enforces several of +them at push time, and the rest guard against damage a rejected push does not undo (a +scope-widened commit, a rewritten shared history, a destructive reset). + +## Staging versus committing + +- **Default to staging, not committing.** Stage with `git add` and leave `git commit` to the + developer unless the developer has explicitly authorized committing for the current ask ("commit + this", "open a PR"). Authorization is scope-bound: it covers the commits that specific task + needs, not a blanket license for the rest of the session. +- **Stage by explicit path, never `git add -A` or `git add .`.** A blanket add stages whatever + else happens to be in the tree, and what it sweeps in is another task's uncommitted work, + landing in a commit whose subject never mentions it, committed by a session that never saw it. + That sweep has happened, which is why task isolation exists (the `repo-worktree` skill), and + isolation makes a shared tree rare rather than impossible. Name the files this task changed, + and let anything else stay unstaged. +- **"Commit" means commit and push.** An authorization to commit carries the push to the feature + branch the work belongs on, because nothing reviews a local commit. The Copilot review loop, the + required status checks, and the maintainer all read the remote, so stopping at `git commit` + leaves the review unstarted and the branch's state private to one machine, which reads as + progress while none of the gates have run. Push to the feature branch, never to a protected + branch, and never with `--force`. Holding a commit locally is the narrower case: it happens when + the developer asks for it, not by default. +- **Check `git status` before committing, and treat any change this session did not make as a + stop.** The maintainer hand-edits files live, often `README.md`/`HISTORY.md`, sometimes with an + editor's LF -> CRLF flip on top, and a sibling agent session sharing the tree leaves its edits + the same way. Whoever the author is, a change this session did not make is never bundled: ask + whether to include it, or leave it unstaged and say so, rather than committing half-finished + work or stranding it in an unrelated commit. An unexpected change in the tree is also the + signal to re-check isolation per the `repo-worktree` skill, since it may mean another task is + live in this checkout. + +## Signing, verified not configured + +- **Every commit must be cryptographically signed (SSH or GPG).** Branch protection enforces this + on every fleet branch, and an unsigned commit is rejected on push. Signing depends on + environment configuration (`commit.gpgsign`, `user.signingkey`, `gpg.format`), but none of those + values prove signing actually works: `gpg.format=ssh` can sign straight from a key file with no + `ssh-agent` running at all (the common case on Git for Windows), just as GPG can sign + agent-backed or straight from a keyring. **Probing agent liveness (`ssh-add -L`, a `gpg-agent` + check) is not a valid test and must not be used.** It tests one specific delivery path, not + whether a commit actually ends up signed, and a host that signs straight from a key file fails + that probe while signing correctly. +- **Verify with a real scratch commit, read back with git's own verdict, not a text grep.** This + single probe is tech-agnostic (SSH agent-backed, SSH key-file, GPG agent-backed, and GPG keyring + all exercise the same code path) and doubles as the identity check below. Run it once before the + first agent-authored commit of a session. Don't assume a prior session left config correct. The + commit below is plain, deliberately no `-S`: forcing it would still succeed on a host where + `commit.gpgsign` is unset or false, which is the exact default-config gap this probe exists to + catch, since every real commit an agent makes is plain too: + + The probe is one physical line, not backslash-joined ones, so it copy-pastes cleanly into a + shell: + + ```sh + d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( trap 'rm -rf "$d"' 0; email=$(git config --global --get user.email) && git init -q "$d" && git -C "$d" commit --allow-empty -q -m check && out=$(git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>') && echo "$out" && ae=$(git -C "$d" log -1 --format='%ae') && ce=$(git -C "$d" log -1 --format='%ce') && case "$out" in sig=G\ *|sig=U\ *) true ;; *) false ;; esac && case "$email" in *@users.noreply.github.com) true ;; *) false ;; esac && [ "$ae" = "$email" ] && [ "$ce" = "$email" ] ) + ``` + + PowerShell equivalent: + + ```powershell + $d = Join-Path $env:TEMP ([guid]::NewGuid()) + try { + $email = git config --global --get user.email + git init -q "$d" ` + && git -C "$d" commit --allow-empty -q -m check + $out = git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' + $out + $ae = git -C "$d" log -1 --format='%ae' + $ce = git -C "$d" log -1 --format='%ce' + if ($out -notmatch '^sig=[GU] ' -or $email -notmatch '@users\.noreply\.github\.com$' ` + -or $ae -ne $email -or $ce -ne $email) { + throw "signing/identity check failed: $out" + } + } finally { + if (Test-Path "$d") { Remove-Item -Recurse -Force "$d" } + } + ``` + + `sig` must read `G` (good signature) or `U` (good signature, unrecognized signer). For GPG, `U` + is a valid signature from a key whose trust level is merely undefined, common right after + generating a new key. For SSH, it's a valid signature from a key not found in the local + `allowed_signers` file, which doesn't affect whether GitHub itself verifies the commit, only + local `git verify-commit` output. `sig` is git's own verdict char. Don't grep localized + "Good" text, since that varies by git version and locale. Anything else, or the commit failing + outright, means **do not commit**: surface the actual error to the developer and stop at + `git add`. Nothing else is contrary evidence: not an unreachable agent, not a config value, not a + signature type you can't otherwise explain in past history (see below). +- **A mix of SSH- and GPG-signed commits in history is structural, not a host to track down.** + `git log --pretty='%G? %GK'` shows two distinct shapes, not two health states: a commit committed + by the PR's own author carries that host's own signature type, while a commit committed by + `GitHub <noreply@github.com>` is a squash-merge: GitHub creates and signs that commit itself, + server-side, with GitHub's own GPG key, regardless of what the PR author signed with locally. + Every commit on `develop`/`main` past its first squash-merge shows `GitHub` as committer and a + GPG signature. That's expected on every fleet repo, on every host, and is not evidence anything + is misconfigured. Check `commit.committer.name` before treating a differing signature type as a + clue worth chasing. +- **Signing must be live before the *first* commit, not retrofitted.** Turning on a + require-signed-commits rule against a branch that already carries unsigned commits forces a + rewrite of that entire history to re-sign it, changing every commit SHA and making whoever does + the rewrite the committer and signer of every commit in it (a rebase preserves `author` but not + the original signatures, and one contributor cannot sign for another). During new-repo setup, + never create commits until signing is verified. + +## Identity, verified not set + +**Commit under the committing account's own GitHub `noreply` identity, never a private, personal, +or invented address.** `author` and `committer` on every agent-authored commit are the GitHub +`noreply` address of the account whose key signs the commit, in `username@users.noreply.github.com` +or `ID+username@users.noreply.github.com` form. **Verify it, do not set it**: the scratch commit +from the signing check above already proves this end-to-end. Read its `author=`/`committer=` +output rather than trusting `git config --get user.email` alone, since a global config value +doesn't prove what actually lands on a commit object, and read both rather than the author alone +since a rebase, amend, or cherry-pick can rewrite the committer while leaving the author +untouched. Match both against that address before committing, rather than +writing a repo-local override. The identity is host configuration set globally once, so a repo-local +`user.email` is redundant where the global is right and a silently-shadowing wrong identity where +it is not. A mismatch is a host fault to surface to the maintainer, not to patch per repo, because +a local override hides a broken host that then commits wrong in every other repo on that machine. +A wrong identity is not cosmetic: a private email trips GitHub's email-privacy push protection, and +an invented author pollutes history. It is also a distinct failure from signing (a wrong author +does not by itself fail the signature check), though the ad-hoc identities that produce one are +typically also unsigned, which the signing rule above then rejects independently. + +## Never force push + +Do not run `git push --force` or `git push --force-with-lease` under any circumstances. Force +pushing rewrites shared history and can cause data loss. This holds regardless of how confident +the rewrite looks, a rejected push is recoverable, a force-pushed one is not. + +## History rewrites re-identify only what changed + +**A history rewrite includes only the commits that must change, and re-identifies any commit it +rewrites that is not the agent's own.** Filtering history (`git filter-repo` or an equivalent, for +example to strip PII) re-signs every commit it touches with the rewriter's own key, while the +tooling preserves each commit's original `author`/`committer` unless told otherwise. GitHub +verifies a signature against the commit's `committer` identity, so a signature from the rewriter's +key over a commit still committed by a bot (`dependabot[bot]`, `github-actions[bot]`) or GitHub's +own web-flow does not match its committer and lands `unknown_key`/unverified, which a +require-signed-commits rule then rejects. + +Two gates keep committer and signature aligned: + +1. **Scope the rewrite to only the commits that must be modified.** By default those are the + rewriter's own, whose committer already matches, so a commit that needs no change stays out of + the rewrite entirely and its identity and signature are never touched. +2. **If a commit that must change is not the rewriter's own, set its `committer` to the rewriter's + own signing identity before re-signing** (and its `author` too, since a rewrite that alters + content should not keep attributing it to the bot). The original bot attribution is deliberately + given up as the cost of having to rewrite it. + +Never leave a signature over a commit committed by another identity. Verify after any rewrite that +every rewritten commit is signed and committed under the correct identity +(`git log --show-signature`). + +## Never run destructive git commands without being asked + +`git reset --hard`, `git checkout .`, `git restore .`, `git clean -f`, and anything else that +discards uncommitted work runs only on explicit developer instruction, never as a convenience step +inside a larger task. diff --git a/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/SKILL.md b/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/SKILL.md index ad30d26c..0834b424 100644 --- a/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/SKILL.md @@ -1,155 +1,155 @@ ---- -name: operational-vs-release-workflow -description: >- - Governs how a ptr727/ProjectTemplate fleet repo branches, promotes, and publishes: the - feature -> develop -> main flow, squash-only vs. merge-commit-only branch protection, the two - develop -> main promotion traps (never delete develop, EOL-only conflicts), the two-phase - publish model (PRs smoke-test only, a human merge never auto-publishes), NBGV semantic - versioning, and the operational-repo delta (direct-to-develop commits, advisory CI, dispatch-only - release) that applies instead whenever the registry's workflowModel field for this repo reads - operational rather than release. Use this whenever choosing a target branch for a change, - promoting develop to main, resolving a develop -> main merge conflict, deciding whether a - release repo's config change needs a PR versus an operational repo's config change can commit - straight to develop, bumping version.json, adding or dropping a release target, or reasoning - about why a merge did or didn't trigger a publish. Triggers even when the request sounds like - ordinary git housekeeping ("just push this config fix", "merge develop into main", "cut a - release"), because the two workflow models genuinely differ (a direct-to-develop commit that is - correct in an operational repo is a rule violation in a release repo, and vice versa) and - applying the wrong one is not obviously wrong to a reader who only knows one of the two. ---- - -# Operational vs. Release Workflow - -## Why this exists - -Two workflow models exist because the underlying repos are two different things. Most fleet repos -ship versioned units of delivery, so they earn a feature -> `develop` -> `main` flow with real -release gates. A handful of repos instead track a live service's running state (Home Assistant, -ESPHome, Vantage, home automation configs) where the "release" is the config already committed, -not something built and shipped later. Applying the release model's ceremony to an operational -repo, or skipping the release model's gates on a repo that actually ships versioned artifacts, is -each wrong in its own repo and correct in the other, which is why this is one skill keyed on which -repo you're in rather than two skills that never talk to each other. - -## Which model this repo uses - -Read the registry `workflowModel` field for this repo (`release`, the default, or `operational`). -The rest of this skill's "Branching" and "Publishing" sections describe the `release` model. The -"Operational repositories" section below is the complete delta for `operational` repos. Anything -not mentioned there is unchanged. When in doubt which one applies, check `registry/repos.json` -rather than guessing from the repo's contents. - -## Branching (release model) - -- **GitHub's repository setting for "default branch" reads `main`, but `develop` is where work starts and where in-flight content lives.** A worktree or clone that defaults to "the default branch" lands on `main` and can silently miss content that has merged to `develop` but not yet been promoted. Before branching off a change, or asserting something absent from this repo, check `develop`, not just whichever branch a tool defaulted to. See GOVERNANCE.md "Verification Discipline" on naming the branch a "does not exist" claim was checked against, and the `repo-worktree` skill, which owns the worktree-creation moment this base-branch choice is made at. -- `develop` is the integration branch. Feature branches -> `develop` is **squash-only**, which - keeps `develop` linear. -- `develop -> main` is **merge-commit only** (no squash, no rebase). Merge commits preserve - `develop`'s commit list as a real second-parent reference on `main`, which lets the release - model attribute releases to the develop commits that produced them. Branch protection enforces - this: the `develop` ruleset allows only `squash`, the `main` ruleset allows only `merge`. -- All commits on both branches must be cryptographically signed (SSH or GPG), see - `git-commit-conventions`. Squash and merge commits created via the GitHub UI are signed by - GitHub's web-flow key. -- **`develop` is forward-only, with no `main -> develop` back-merges.** The `develop` ruleset's - squash-only setting physically blocks merge commits on `develop`. Any historical back-merge - commits in `git log` predate this rule and must not be repeated. -- **Never delete `develop`, and take the EOL-only conflict by taking develop's side.** A - promotion PR's head *is* `develop`, so `--delete-branch` deletes it. An EOL-only conflict on a - workflow YAML file resolves on a throwaway branch off `main`, not on `develop`. Full recovery and - conflict-resolution commands: `references/branch-protection-and-promotion.md`. -- **Issue-closing keywords (`Closes #N`, `Fixes #N`) go in the `develop -> main` promotion PR, not - the feature -> `develop` PR.** GitHub auto-closes an issue only when the closing keyword merges - into the **default branch** (`main`), so a feature -> `develop` PR merge never fires it. - Reference the issue in the `develop` PR body if useful, but the actual closing keyword belongs on - the promotion PR. Closing by hand is the ordinary route wherever the keyword cannot fire (a - promotion that already merged without it, or completed work with no promotion imminent), not a - repair for a botched promotion, cite the squash SHA and re-read that commit before closing. -- **Neither ruleset requires branches to be up to date before merging**, for different reasons on - each branch (a graph-based check that would fail every release on `main`, a check that stalls - bot auto-merge on `develop`). Detail: `references/branch-protection-and-promotion.md`. -- **Configuring branch protection: import the committed ruleset payloads, don't hand-build them.** - Exactly two rulesets, named `develop` and `main`. Full procedure, including the operational - `develop` payload and the brownfield-repo signing caveat: - `references/branch-protection-and-promotion.md`. -- **Dependabot and codegen target both `main` and `develop` in parallel**, each branch absorbing - its own bot PRs independently so neither falls behind, with the merge-bot dispatching the merge - form (`--squash`/`--merge`) that matches each PR's base ruleset. Codegen output must be - deterministic from its inputs alone, never per-run state, or the two branches' legs conflict on - every promotion. Full mechanics: `references/branch-protection-and-promotion.md`. -- **App-token workflows authenticate with Client ID, not the deprecated App ID.** Use - `client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}` at any new App-token call site. - -## Publishing (release model) - -- **The two-phase model is the default: PRs build fast, publishing is batched.** A PR only - smoke-tests (unit tests plus a reduced build of the changed targets), it never pushes anything. - `publish-release.yml` is the sole publisher, and each run builds a **single trigger branch** - (`main` a release, `develop` a prerelease). -- **A human merge never auto-publishes.** Publishing fires on a **`workflow_dispatch`** of - `main`/`develop` (a human-initiated release), a **code-affecting bot push to `main`** (the - codegen App merging a Dependabot/codegen PR, gated on `github.actor` so a human - merge/promotion skips it), or a **weekly `schedule`** (Docker only, to refresh the base image). - A source-only repo publishes on dispatch only. -- **The changes-detection job is a required check that must succeed, not just not fail.** A - paths-filter error must never let a target-changing PR merge with its smoke build silently - skipped. A skipped smoke job (no matching change) passes, `failure`/`cancelled` blocks. -- **Versioning is semantic and maintainer-controlled.** `version.json`'s `major.minor` is the - version floor, edited by the maintainer for functional changes only, in the PR that introduces - the work, never on a fixed cadence or mechanically after a release. NBGV appends the git height - automatically on every commit, so a release always gets a fresh build version with **no - post-release bump** and no develop-ahead requirement. -- **Docs reference the 2-digit `major.minor` line, never a 3-digit build.** `README.md`, - `HISTORY.md`, and release notes name the version as `Version 1.0` (the floor), never the concrete - build height, which is both wrong (the real height differs) and a maintenance trap. - "Correcting" `1.0` to `1.0.0` is a defect. -- **A no-op publish (unchanged NBGV `SemVer2`) re-pushes nothing to any target keyed on the - version string, except Docker, which always re-pushes** to pick up upstream base-image - refreshes. Full guarantee and the `version.json` `pathFilters` boundary: - `references/release-publish-mechanics.md`. -- **Adding, dropping, or wiring a release target** (which leaf task, which artifact-naming - contract, which seam a given output belongs to: a GitHub Release asset, a package-registry push, - an image-registry push, a filesystem deploy, or a source-only repo with no build layer at all), - and tracking an upstream release from a wrapper repo: `references/release-publish-mechanics.md`. - See also `WORKFLOW.md` for the full CI/CD contract this section's rules are load-bearing - excerpts of. - -## Operational repositories (the complete delta) - -Everything above is the `release` model. An `operational` repo (registry `workflowModel: -operational`) tracks a live service's running state rather than shipping versioned units of -delivery, and differs from the `release` model in exactly these ways, everything not listed here -stays the same: - -- **Commit configuration directly to `develop`.** There is no feature branch requirement, the - maintainer commits straight to `develop`, and only *occasionally* opens a `develop -> main` PR to - bless a known-good snapshot. The `develop` ruleset drops the PR and status-check gate, so direct - signed pushes are allowed (force-push, deletion, and unsigned commits are still blocked), and CI - runs on the push as **advisory** feedback that never rejects a commit. -- **A PR into `develop` stays available, and CI runs on it, reported but not required.** Dropping - the requirement permits the direct push, it does not withdraw the pull request, so a change worth - reviewing takes one and both paths into `develop` are legitimate. -- **Take the pull request whenever the change is not one a reader takes in at a glance and - reverts cleanly.** What decides it is the shape of the change, not a line count: restructuring - rather than adjusting a value, touching several files at once, introducing a device, an - integration, or an automation that did not exist before, and anything whose failure shows up on - the live service rather than in a lint run are each the pull request case. So is a change the - author cannot state in one sentence. This stays a judgment call by design, adding a - `pull_request` rule to the operational `develop` ruleset would gate the direct push too and - withdraw the allowance the model exists to give. -- **The `main` promotion gate is unchanged.** The shared `main` ruleset still **enforces** the - required `Check pull request workflow status job` on the `develop -> main` PR. For an operational - repo that check is lint/validation only (editorconfig/EOL plus a domain linter such as a Home - Assistant or ESPHome config validation, never unit tests), so `develop` stays the live surface - and a broken config can never reach `main`. -- **Release only by manual dispatch.** Operational repos carry `releaseTrigger: dispatch-only` and - run no codegen or auto-publish bots, publishing **only** on a manual `workflow_dispatch` (the - same source-only release the publisher already supports: tag, source zip, README, LICENSE, - NBGV-versioned), never automatically. The `develop -> main` promotion just blesses a known-good - snapshot, a release is a separate, deliberate dispatch. -- **Fleet sync still applies.** Dependabot's dual-target sync and the App-signed merge-bot run on - **every** tier, operational included, so both branches stay in sync and a promotion stays a - clean forward merge. -- **Line-ending policy differs too**, following the consuming app's native platform rather than the - fleet CRLF default, per the registry `lineEndings` field. That rule belongs to - `comment-and-doc-style`, not repeated here. +--- +name: operational-vs-release-workflow +description: >- + Governs how a ptr727/ProjectTemplate fleet repo branches, promotes, and publishes: the + feature -> develop -> main flow, squash-only vs. merge-commit-only branch protection, the two + develop -> main promotion traps (never delete develop, EOL-only conflicts), the two-phase + publish model (PRs smoke-test only, a human merge never auto-publishes), NBGV semantic + versioning, and the operational-repo delta (direct-to-develop commits, advisory CI, dispatch-only + release) that applies instead whenever the registry's workflowModel field for this repo reads + operational rather than release. Use this whenever choosing a target branch for a change, + promoting develop to main, resolving a develop -> main merge conflict, deciding whether a + release repo's config change needs a PR versus an operational repo's config change can commit + straight to develop, bumping version.json, adding or dropping a release target, or reasoning + about why a merge did or didn't trigger a publish. Triggers even when the request sounds like + ordinary git housekeeping ("just push this config fix", "merge develop into main", "cut a + release"), because the two workflow models genuinely differ (a direct-to-develop commit that is + correct in an operational repo is a rule violation in a release repo, and vice versa) and + applying the wrong one is not obviously wrong to a reader who only knows one of the two. +--- + +# Operational vs. Release Workflow + +## Why this exists + +Two workflow models exist because the underlying repos are two different things. Most fleet repos +ship versioned units of delivery, so they earn a feature -> `develop` -> `main` flow with real +release gates. A handful of repos instead track a live service's running state (Home Assistant, +ESPHome, Vantage, home automation configs) where the "release" is the config already committed, +not something built and shipped later. Applying the release model's ceremony to an operational +repo, or skipping the release model's gates on a repo that actually ships versioned artifacts, is +each wrong in its own repo and correct in the other, which is why this is one skill keyed on which +repo you're in rather than two skills that never talk to each other. + +## Which model this repo uses + +Read the registry `workflowModel` field for this repo (`release`, the default, or `operational`). +The rest of this skill's "Branching" and "Publishing" sections describe the `release` model. The +"Operational repositories" section below is the complete delta for `operational` repos. Anything +not mentioned there is unchanged. When in doubt which one applies, check `registry/repos.json` +rather than guessing from the repo's contents. + +## Branching (release model) + +- **GitHub's repository setting for "default branch" reads `main`, but `develop` is where work starts and where in-flight content lives.** A worktree or clone that defaults to "the default branch" lands on `main` and can silently miss content that has merged to `develop` but not yet been promoted. Before branching off a change, or asserting something absent from this repo, check `develop`, not just whichever branch a tool defaulted to. See GOVERNANCE.md "Verification Discipline" on naming the branch a "does not exist" claim was checked against, and the `repo-worktree` skill, which owns the worktree-creation moment this base-branch choice is made at. +- `develop` is the integration branch. Feature branches -> `develop` is **squash-only**, which + keeps `develop` linear. +- `develop -> main` is **merge-commit only** (no squash, no rebase). Merge commits preserve + `develop`'s commit list as a real second-parent reference on `main`, which lets the release + model attribute releases to the develop commits that produced them. Branch protection enforces + this: the `develop` ruleset allows only `squash`, the `main` ruleset allows only `merge`. +- All commits on both branches must be cryptographically signed (SSH or GPG), see + `git-commit-conventions`. Squash and merge commits created via the GitHub UI are signed by + GitHub's web-flow key. +- **`develop` is forward-only, with no `main -> develop` back-merges.** The `develop` ruleset's + squash-only setting physically blocks merge commits on `develop`. Any historical back-merge + commits in `git log` predate this rule and must not be repeated. +- **Never delete `develop`, and take the EOL-only conflict by taking develop's side.** A + promotion PR's head *is* `develop`, so `--delete-branch` deletes it. An EOL-only conflict on a + workflow YAML file resolves on a throwaway branch off `main`, not on `develop`. Full recovery and + conflict-resolution commands: `references/branch-protection-and-promotion.md`. +- **Issue-closing keywords (`Closes #N`, `Fixes #N`) go in the `develop -> main` promotion PR, not + the feature -> `develop` PR.** GitHub auto-closes an issue only when the closing keyword merges + into the **default branch** (`main`), so a feature -> `develop` PR merge never fires it. + Reference the issue in the `develop` PR body if useful, but the actual closing keyword belongs on + the promotion PR. Closing by hand is the ordinary route wherever the keyword cannot fire (a + promotion that already merged without it, or completed work with no promotion imminent), not a + repair for a botched promotion, cite the squash SHA and re-read that commit before closing. +- **Neither ruleset requires branches to be up to date before merging**, for different reasons on + each branch (a graph-based check that would fail every release on `main`, a check that stalls + bot auto-merge on `develop`). Detail: `references/branch-protection-and-promotion.md`. +- **Configuring branch protection: import the committed ruleset payloads, don't hand-build them.** + Exactly two rulesets, named `develop` and `main`. Full procedure, including the operational + `develop` payload and the brownfield-repo signing caveat: + `references/branch-protection-and-promotion.md`. +- **Dependabot and codegen target both `main` and `develop` in parallel**, each branch absorbing + its own bot PRs independently so neither falls behind, with the merge-bot dispatching the merge + form (`--squash`/`--merge`) that matches each PR's base ruleset. Codegen output must be + deterministic from its inputs alone, never per-run state, or the two branches' legs conflict on + every promotion. Full mechanics: `references/branch-protection-and-promotion.md`. +- **App-token workflows authenticate with Client ID, not the deprecated App ID.** Use + `client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}` at any new App-token call site. + +## Publishing (release model) + +- **The two-phase model is the default: PRs build fast, publishing is batched.** A PR only + smoke-tests (unit tests plus a reduced build of the changed targets), it never pushes anything. + `publish-release.yml` is the sole publisher, and each run builds a **single trigger branch** + (`main` a release, `develop` a prerelease). +- **A human merge never auto-publishes.** Publishing fires on a **`workflow_dispatch`** of + `main`/`develop` (a human-initiated release), a **code-affecting bot push to `main`** (the + codegen App merging a Dependabot/codegen PR, gated on `github.actor` so a human + merge/promotion skips it), or a **weekly `schedule`** (Docker only, to refresh the base image). + A source-only repo publishes on dispatch only. +- **The changes-detection job is a required check that must succeed, not just not fail.** A + paths-filter error must never let a target-changing PR merge with its smoke build silently + skipped. A skipped smoke job (no matching change) passes, `failure`/`cancelled` blocks. +- **Versioning is semantic and maintainer-controlled.** `version.json`'s `major.minor` is the + version floor, edited by the maintainer for functional changes only, in the PR that introduces + the work, never on a fixed cadence or mechanically after a release. NBGV appends the git height + automatically on every commit, so a release always gets a fresh build version with **no + post-release bump** and no develop-ahead requirement. +- **Docs reference the 2-digit `major.minor` line, never a 3-digit build.** `README.md`, + `HISTORY.md`, and release notes name the version as `Version 1.0` (the floor), never the concrete + build height, which is both wrong (the real height differs) and a maintenance trap. + "Correcting" `1.0` to `1.0.0` is a defect. +- **A no-op publish (unchanged NBGV `SemVer2`) re-pushes nothing to any target keyed on the + version string, except Docker, which always re-pushes** to pick up upstream base-image + refreshes. Full guarantee and the `version.json` `pathFilters` boundary: + `references/release-publish-mechanics.md`. +- **Adding, dropping, or wiring a release target** (which leaf task, which artifact-naming + contract, which seam a given output belongs to: a GitHub Release asset, a package-registry push, + an image-registry push, a filesystem deploy, or a source-only repo with no build layer at all), + and tracking an upstream release from a wrapper repo: `references/release-publish-mechanics.md`. + See also `WORKFLOW.md` for the full CI/CD contract this section's rules are load-bearing + excerpts of. + +## Operational repositories (the complete delta) + +Everything above is the `release` model. An `operational` repo (registry `workflowModel: +operational`) tracks a live service's running state rather than shipping versioned units of +delivery, and differs from the `release` model in exactly these ways, everything not listed here +stays the same: + +- **Commit configuration directly to `develop`.** There is no feature branch requirement, the + maintainer commits straight to `develop`, and only *occasionally* opens a `develop -> main` PR to + bless a known-good snapshot. The `develop` ruleset drops the PR and status-check gate, so direct + signed pushes are allowed (force-push, deletion, and unsigned commits are still blocked), and CI + runs on the push as **advisory** feedback that never rejects a commit. +- **A PR into `develop` stays available, and CI runs on it, reported but not required.** Dropping + the requirement permits the direct push, it does not withdraw the pull request, so a change worth + reviewing takes one and both paths into `develop` are legitimate. +- **Take the pull request whenever the change is not one a reader takes in at a glance and + reverts cleanly.** What decides it is the shape of the change, not a line count: restructuring + rather than adjusting a value, touching several files at once, introducing a device, an + integration, or an automation that did not exist before, and anything whose failure shows up on + the live service rather than in a lint run are each the pull request case. So is a change the + author cannot state in one sentence. This stays a judgment call by design, adding a + `pull_request` rule to the operational `develop` ruleset would gate the direct push too and + withdraw the allowance the model exists to give. +- **The `main` promotion gate is unchanged.** The shared `main` ruleset still **enforces** the + required `Check pull request workflow status job` on the `develop -> main` PR. For an operational + repo that check is lint/validation only (editorconfig/EOL plus a domain linter such as a Home + Assistant or ESPHome config validation, never unit tests), so `develop` stays the live surface + and a broken config can never reach `main`. +- **Release only by manual dispatch.** Operational repos carry `releaseTrigger: dispatch-only` and + run no codegen or auto-publish bots, publishing **only** on a manual `workflow_dispatch` (the + same source-only release the publisher already supports: tag, source zip, README, LICENSE, + NBGV-versioned), never automatically. The `develop -> main` promotion just blesses a known-good + snapshot, a release is a separate, deliberate dispatch. +- **Fleet sync still applies.** Dependabot's dual-target sync and the App-signed merge-bot run on + **every** tier, operational included, so both branches stay in sync and a promotion stays a + clean forward merge. +- **Line-ending policy differs too**, following the consuming app's native platform rather than the + fleet LF default, per the registry `lineEndings` field. That rule belongs to + `comment-and-doc-style`, not repeated here. diff --git a/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/references/branch-protection-and-promotion.md b/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/references/branch-protection-and-promotion.md index 94a9068a..f30abd0a 100644 --- a/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/references/branch-protection-and-promotion.md +++ b/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/references/branch-protection-and-promotion.md @@ -1,113 +1,114 @@ -# Branch Protection and Promotion Mechanics - -Full detail for the "Branching" rules in `SKILL.md`. Load this when configuring or reconstructing -branch protection on a fleet repo, executing a `develop -> main` promotion, recovering a lost -`develop`, resolving an EOL-only promotion conflict, or working on the dual-target bot wiring -(Dependabot, codegen, the merge-bot), not for an ordinary feature-branch PR (the SKILL.md summary -covers that case). - -## Configuring branch protection: don't hand-build the rules - -Reconstructing rulesets by hand is error-prone and has gone wrong on past ports. First delete -**all** legacy classic branch-protection rules and any stray rulesets (rulesets are the *only* -mechanism used), then create **exactly two rulesets named `develop` and `main`** by importing the -committed `repo-config/*.json` ruleset payloads via `gh api -X POST "repos/<owner>/<repo>/rulesets"` -(`gh ruleset` is read-only). The names are load-bearing, other governance content and the -workflows reference them. Operational repos import `repo-config/operational/develop.json` as their -`develop` ruleset (the `main` ruleset is shared), and the hub's `repo-config/configure.sh`, run -from a hub checkout against the repository named on its command line, selects the right `develop` -payload from the registry `workflowModel` automatically. **Brownfield repos** (pre-existing -history) need an extra step: `Require signed commits` rejects legacy unsigned commits and the -admin bypass does not cover `git push --force`, so re-signing requires temporarily disabling the -ruleset. See `repo-config/README.md` "Rulesets" for the configured state. - -## 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 `repo-config/settings.json` - 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/<owner>/<repo>/git/refs" -f ref=refs/heads/develop -f sha="$(gh pr view <n> --json headRefOid --jq .headRefOid)"`. -- **Spurious EOL-only conflicts resolve by taking `develop`.** When `develop` declares workflow - YAML as LF while `main` is still CRLF, `develop -> main` conflicts *whole-file* on those paths. - `develop`'s `required_linear_history` plus PR rulesets forbid resolving on `develop` (no merge - commit, no force-push), so resolve on a throwaway branch off `main`: - `git checkout -b promote/develop-to-main origin/main && git merge origin/develop`, take - `develop`'s side for the EOL-conflicted files (`git checkout --theirs <file>`) **after - confirming each is content-identical modulo EOL, or that `develop` is a strict superset** - (`diff <(git show :2:f | tr -d '\r') <(git show :3:f | tr -d '\r')`), then open that branch into - `main`. Verify no genuine `main`-only content is dropped (build/test where the repo supports it). - -## Why both rulesets omit "Require branches to be up to date before merging" - -The flag is off on `main` and on `develop`, for related but distinct reasons. - -- **Main**: the check is graph-based, it asks whether `main`'s tip commit is reachable from - `develop`, not whether the two branches have the same content. After any `develop -> main` - release, `main`'s tip is a brand-new merge commit that `develop`'s history doesn't contain. - Forward-only `develop` never adds it (no back-merge of `main` into `develop`), so the check - would fail on every subsequent release. Other technical workarounds (rebasing `develop` onto - `main`, or rewriting `develop`'s history) exist but contradict the squash-only `develop` ruleset - and the linearity invariant. -- **Develop**: the check stalls bot auto-merge when two bot PRs against `develop` land within the - same window. As soon as the first merges, the second flips to `mergeStateStatus: BEHIND` and - GitHub's auto-merge will not fire while strict is on. The merge-bot only *enables* auto-merge on - `opened`/`reopened` and never auto-updates bot branches, and Dependabot's rebase isn't real-time, - so the second PR sits OPEN with all checks green indefinitely. Squash mechanics still rebase the - diff onto `develop`'s tip on merge, `required_linear_history` still enforces linearity, textual - conflicts still block `mergeable: CONFLICTING`, and the required `Check pull request workflow - status job` still gates merges. The only thing lost is pre-merge detection of - *semantic-but-not-textual* conflicts, which the post-merge `develop` CI run catches anyway. - -## Dual-target bots - -**Dependabot and codegen target both `main` and `develop` in parallel.** -`.github/dependabot.yml` duplicates every ecosystem entry (one per branch) and the codegen -workflow runs as a matrix over both branches with branch names `codegen-main` and -`codegen-develop`. Each branch absorbs its own bot PRs independently, so neither falls behind, and -the forward-only rule still holds, nothing is back-merged from `main` to `develop`, both branches -receive their updates directly. The merge-bot (`.github/workflows/merge-bot-pull-request.yml`) -dispatches `--squash` or `--merge` from each PR's base ref via a `case` statement so the form -matches the ruleset on either base. Dependabot **security** PRs (CVE-driven) always open against -the repo default branch (`main`) regardless of `target-branch`, and the same `case` statement -covers them. The merge-bot auto-merges **every** Dependabot tier including semver-major (no -ecosystem or update-type guard), the required CI checks are the gate, not the bump magnitude, so a -major that breaks the build fails its checks and never merges. - -**Why parallel dual-target rather than develop-only with eventual flow-through:** -push-distribution channels (HACS for Home Assistant integrations, Linux distros that vendor from -`main`, etc.) consume `main` directly. A develop-only model would leave `main` running stale code -during long-running develop features. Codegen content can also be production-critical (live -API-derived data, language lists, build catalogs) rather than just sample/demo content, so both -branches need fresh codegen on their own cadence. - -**Maintainer-pushed commits on a bot PR auto-disable auto-merge.** The merge-bot's -`merge-dependabot` and `merge-codegen` jobs only fire on `opened`/`reopened` events (auto-merge is -enabled exactly once per PR). When a maintainer pushes commits to a bot's branch (a `synchronize` -event with an actor that isn't the same bot), the merge-bot's -`disable-auto-merge-on-maintainer-push` job fires and calls `gh pr merge --disable-auto`. The -maintainer's commits stay in the PR but won't auto-merge with the bot's content. Re-enable -auto-merge manually (`gh pr merge --auto <PR>` or the GitHub UI) when ready. - -## Codegen determinism - -The codegen workflow is a mechanism to refresh files that are checked into the repo: it runs a -matrix over `main` and `develop`, each leg regenerating against its own checkout and opening its -own PR (`codegen-main -> main`, `codegen-develop -> develop`). For the two legs not to conflict on -`develop -> main`, the generated output must depend only on its inputs, never on per-invocation -state (timestamps, GUIDs, build IDs), which would diverge every run and conflict on every release. -**What** a repo regenerates (data files, source, or both) and **how** (download and process an -external source, transform local inputs, whatever) is entirely its own concern. The constraint is -only that the output be input-deterministic, not how it is produced. A repo adopting codegen -supplies its own input-deterministic generator and wires the codegen reference workflow -(`run-codegen-pull-request-task.yml` and its scheduler). - -## App-token workflows use Client ID, not App ID - -`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 `repo-config/README.md` -"Secrets" for which secrets each mechanism needs. +# Branch Protection and Promotion Mechanics + +Full detail for the "Branching" rules in `SKILL.md`. Load this when configuring or reconstructing +branch protection on a fleet repo, executing a `develop -> main` promotion, recovering a lost +`develop`, resolving an EOL-only promotion conflict, or working on the dual-target bot wiring +(Dependabot, codegen, the merge-bot), not for an ordinary feature-branch PR (the SKILL.md summary +covers that case). + +## Configuring branch protection: don't hand-build the rules + +Reconstructing rulesets by hand is error-prone and has gone wrong on past ports. First delete +**all** legacy classic branch-protection rules and any stray rulesets (rulesets are the *only* +mechanism used), then create **exactly two rulesets named `develop` and `main`** by importing the +committed `repo-config/*.json` ruleset payloads via `gh api -X POST "repos/<owner>/<repo>/rulesets"` +(`gh ruleset` is read-only). The names are load-bearing, other governance content and the +workflows reference them. Operational repos import `repo-config/operational/develop.json` as their +`develop` ruleset (the `main` ruleset is shared), and the hub's `repo-config/configure.sh`, run +from a hub checkout against the repository named on its command line, selects the right `develop` +payload from the registry `workflowModel` automatically. **Brownfield repos** (pre-existing +history) need an extra step: `Require signed commits` rejects legacy unsigned commits and the +admin bypass does not cover `git push --force`, so re-signing requires temporarily disabling the +ruleset. See `repo-config/README.md` "Rulesets" for the configured state. + +## 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 `repo-config/settings.json` + 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/<owner>/<repo>/git/refs" -f ref=refs/heads/develop -f sha="$(gh pr view <n> --json headRefOid --jq .headRefOid)"`. +- **Spurious EOL-only conflicts resolve by taking `develop`.** When `develop`'s `.editorconfig` + line-ending default has changed (for example the fleet-wide CRLF-to-LF flip) while `main` hasn't + caught up yet, `develop -> main` conflicts *whole-file* on every renormalized path. + `develop`'s `required_linear_history` plus PR rulesets forbid resolving on `develop` (no merge + commit, no force-push), so resolve on a throwaway branch off `main`: + `git checkout -b promote/develop-to-main origin/main && git merge origin/develop`, take + `develop`'s side for the EOL-conflicted files (`git checkout --theirs <file>`) **after + confirming each is content-identical modulo EOL, or that `develop` is a strict superset** + (`diff <(git show :2:f | tr -d '\r') <(git show :3:f | tr -d '\r')`), then open that branch into + `main`. Verify no genuine `main`-only content is dropped (build/test where the repo supports it). + +## Why both rulesets omit "Require branches to be up to date before merging" + +The flag is off on `main` and on `develop`, for related but distinct reasons. + +- **Main**: the check is graph-based, it asks whether `main`'s tip commit is reachable from + `develop`, not whether the two branches have the same content. After any `develop -> main` + release, `main`'s tip is a brand-new merge commit that `develop`'s history doesn't contain. + Forward-only `develop` never adds it (no back-merge of `main` into `develop`), so the check + would fail on every subsequent release. Other technical workarounds (rebasing `develop` onto + `main`, or rewriting `develop`'s history) exist but contradict the squash-only `develop` ruleset + and the linearity invariant. +- **Develop**: the check stalls bot auto-merge when two bot PRs against `develop` land within the + same window. As soon as the first merges, the second flips to `mergeStateStatus: BEHIND` and + GitHub's auto-merge will not fire while strict is on. The merge-bot only *enables* auto-merge on + `opened`/`reopened` and never auto-updates bot branches, and Dependabot's rebase isn't real-time, + so the second PR sits OPEN with all checks green indefinitely. Squash mechanics still rebase the + diff onto `develop`'s tip on merge, `required_linear_history` still enforces linearity, textual + conflicts still block `mergeable: CONFLICTING`, and the required `Check pull request workflow + status job` still gates merges. The only thing lost is pre-merge detection of + *semantic-but-not-textual* conflicts, which the post-merge `develop` CI run catches anyway. + +## Dual-target bots + +**Dependabot and codegen target both `main` and `develop` in parallel.** +`.github/dependabot.yml` duplicates every ecosystem entry (one per branch) and the codegen +workflow runs as a matrix over both branches with branch names `codegen-main` and +`codegen-develop`. Each branch absorbs its own bot PRs independently, so neither falls behind, and +the forward-only rule still holds, nothing is back-merged from `main` to `develop`, both branches +receive their updates directly. The merge-bot (`.github/workflows/merge-bot-pull-request.yml`) +dispatches `--squash` or `--merge` from each PR's base ref via a `case` statement so the form +matches the ruleset on either base. Dependabot **security** PRs (CVE-driven) always open against +the repo default branch (`main`) regardless of `target-branch`, and the same `case` statement +covers them. The merge-bot auto-merges **every** Dependabot tier including semver-major (no +ecosystem or update-type guard), the required CI checks are the gate, not the bump magnitude, so a +major that breaks the build fails its checks and never merges. + +**Why parallel dual-target rather than develop-only with eventual flow-through:** +push-distribution channels (HACS for Home Assistant integrations, Linux distros that vendor from +`main`, etc.) consume `main` directly. A develop-only model would leave `main` running stale code +during long-running develop features. Codegen content can also be production-critical (live +API-derived data, language lists, build catalogs) rather than just sample/demo content, so both +branches need fresh codegen on their own cadence. + +**Maintainer-pushed commits on a bot PR auto-disable auto-merge.** The merge-bot's +`merge-dependabot` and `merge-codegen` jobs only fire on `opened`/`reopened` events (auto-merge is +enabled exactly once per PR). When a maintainer pushes commits to a bot's branch (a `synchronize` +event with an actor that isn't the same bot), the merge-bot's +`disable-auto-merge-on-maintainer-push` job fires and calls `gh pr merge --disable-auto`. The +maintainer's commits stay in the PR but won't auto-merge with the bot's content. Re-enable +auto-merge manually (`gh pr merge --auto <PR>` or the GitHub UI) when ready. + +## Codegen determinism + +The codegen workflow is a mechanism to refresh files that are checked into the repo: it runs a +matrix over `main` and `develop`, each leg regenerating against its own checkout and opening its +own PR (`codegen-main -> main`, `codegen-develop -> develop`). For the two legs not to conflict on +`develop -> main`, the generated output must depend only on its inputs, never on per-invocation +state (timestamps, GUIDs, build IDs), which would diverge every run and conflict on every release. +**What** a repo regenerates (data files, source, or both) and **how** (download and process an +external source, transform local inputs, whatever) is entirely its own concern. The constraint is +only that the output be input-deterministic, not how it is produced. A repo adopting codegen +supplies its own input-deterministic generator and wires the codegen reference workflow +(`run-codegen-pull-request-task.yml` and its scheduler). + +## App-token workflows use Client ID, not App ID + +`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 `repo-config/README.md` +"Secrets" for which secrets each mechanism needs. diff --git a/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/references/release-publish-mechanics.md b/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/references/release-publish-mechanics.md index 4847f1d0..d4bf7bcd 100644 --- a/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/references/release-publish-mechanics.md +++ b/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/references/release-publish-mechanics.md @@ -1,124 +1,124 @@ -# Release Build and Publish Mechanics - -Full detail for the "Publishing" rules in `SKILL.md`. Load this when adding or removing a release -target, wiring a new leaf build task, deciding where a build output belongs (a GitHub Release -asset, a package-registry push, an image push, a deploy), or setting up a wrapper repo that tracks -an upstream release, not for reading the release model's shape (the SKILL.md summary covers that). - -## Reusable-task parameter contract - -Every `build-*-task.yml` and `build-release-task.yml` takes `ref` (git ref to check out/version), -`branch` (logical branch driving config/tags/prerelease, where `main` => Release/`latest`/ -non-prerelease, else Debug/`develop`/prerelease), and where relevant `smoke`. -**Branch-derived config keys off `inputs.branch`**: each run builds one branch, and the top-level -publisher passes `branch: ${{ github.ref_name }}`, which the tasks forward and read as -`inputs.branch` (not `github.ref_name`) for config/tags/prerelease. `get-version-task.yml` takes a -`ref` so NBGV versions the right branch. - -## Per-target subsetting - -`build-release-task.yml` has per-target `enable_*` gates and self-contained leaf tasks, so a -project that drops a target deletes: its `build-<target>-task.yml`, the matching job plus -`github-release`'s `needs` entry in `build-release-task.yml`, its path-filter entry in -`test-pull-request.yml`, and (for PyPI) the `publish-pypi` job in `publish-release.yml`. CodeGen, -versioning, badge, merge-bot, and Dependabot 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` -plus `github-release` jobs inside `build-release-task.yml`, `get-version-task.yml`, -`build-datebadge-task.yml`, and the aggregator shape of `test-pull-request.yml`. Within -`test-pull-request.yml`, only the `changes -> smoke-build -> check-workflow-status` aggregator -wiring and the ruleset-bound job name are verbatim orchestration, while the `unit-test` job and -the `dorny/paths-filter` entries are owned/per-target. The **build** layer (the -`build-<target>-task.yml` leaf tasks) is what a derived project owns and replaces. - -The contract that keeps the seam clean: **a target contributes files to the GitHub release by -uploading a workflow artifact named `release-asset-<branch>-<target>`.** The `github-release` job -collects every `release-asset-<branch>-*` 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-<branch>-<target>` 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): the *list* of leaf jobs in -`build-release-task.yml`. Per the per-target subsetting rule above, delete the target jobs not -shipped and add the ones that are. `build-release-task.yml`'s `github-release` job is untouched, but the file -is not byte-identical because its `needs`/job list reflects the repo's own targets. Making that -list itself target-agnostic is the release-chain phase of `docs/reusable-workflows.md` in the -hub, where the orchestrator becomes a hub-hosted task and each target a composite-action hook, -and until that phase ships the list stays per repo. - -## Map your outputs to the right seam - -Pick by where each artifact *goes*, not by language: - -- **Files attached to the GitHub Release** (zips, binaries, packaged libraries): one leaf task per - output, each uploading `release-asset-<branch>-<name>`. A data-only repo (e.g. a symbol library) - has exactly one such task: validate -> `zip` -> upload `release-asset-<branch>-library`. It - deletes the nuget/pypi/executable/docker jobs and the `publish-pypi` job, keeps `github-release` - as-is. This is also where the .NET `build-executable-task` lives, and it is *not* a generic file - step but specifically `dotnet publish` of the console app, so replace it wholesale, don't adapt - it. -- **Package-registry pushes** (NuGet.org, PyPI): the leaf task both builds **and** publishes to - its registry. NuGet pushes from inside `build-nugetlibrary-task` (`dotnet nuget push - --skip-duplicate`) *and* also uploads a `release-asset-*` (.7z) for the GitHub release. PyPI is - split: `build-pypilibrary-task` only builds and uploads the `pypilibrary-build-<branch>` - artifact, and the separate `publish-pypi` job in `publish-release.yml` does the OIDC - Trusted-Publishing upload (`id-token: write` is granted only at that one entry point), and PyPI - contributes **no** `release-asset-*`. -- **Image-registry pushes** (Docker Hub): `build-docker-task` pushes multi-arch tags directly and - contributes **no** `release-asset-*`. The image tag is build-layer-owned, so drive it from - whatever version source fits (NBGV `SemVer2`, an upstream-release pin, or a per-image matrix). - To publish the Docker Hub repository overview, `publish-docker-readme-task.yml` pushes - `Docker/README.md` via `peter-evans/dockerhub-description` (single-repo by default, matrix per - image for multi-image repos), wired into `publish-release.yml` and gated to `main`. -- **Filesystem on a host the project owns** (a static site, a config tree): a deploy leaf builds - the tree and ships it over the repo's own transport, contributing **no** `release-asset-*`. It - is a **separate `workflow_dispatch`** from the release, so a redeploy of an unchanged commit - mints no tag, and its credentials come from a **per-environment GitHub Environment** rather than - the repository secret store. Its last step asserts what the host actually serves, the release id - and the environment, never that the transport exited zero. Retention at the destination is - bounded by a declared count, and one side is recorded as owning the prune: the deploy where its - credential can observe the destination, the host where that credential is deliberately - write-only. -- **Source-only / no build** (validate + tag + release): this seam does not apply. A source-only - repo carries **no** `build-release-task.yml` (its `appliesTo` excludes it), so there are no leaf - tasks and no `get-version`/`github-release`/`date-badge` jobs to curate. Its whole release is - the standalone `publish-release.yml` on `workflow_dispatch`: a `validate` job (the repo's - reusable validation task) gates a publish job that **inlines** NBGV for the tag and - `action-gh-release` for the release (tag, auto source archive, README, LICENSE). - -`get-version-task.yml` installs the .NET SDK only because NBGV needs the runtime to compute the -version/tag, which is heavyweight but expected even for a non-.NET repo, and acceptable as-is. - -## No-op republish guarantee - -A weekly/dispatch publish where NBGV `SemVer2` is **unchanged** (no new commit since the last -publish) re-pushes **nothing** to GitHub Releases (the `github-release` job's `release-exists` -check skips the create step), NuGet (`dotnet nuget push --skip-duplicate`), or PyPI -(`gh-action-pypi-publish` `skip-existing: true`), since all three key on the version string. -**Docker always re-pushes** by design: it picks up upstream base-image refreshes (e.g. -`ubuntu:rolling`) that aren't visible in the repo. Boundary: `version.json` has **no -`pathFilters`**, so *any* commit, including a CI/workflow-only or docs-only change, advances the -NBGV git height and therefore `SemVer2`, and the next publish *does* create a fresh release for it -even when the shipped binary is byte-identical. This is accepted NBGV behavior, and `pathFilters` -are intentionally not added. - -## Wrapper repos that track an upstream release - -A repo wrapping an upstream release uses `check-upstream-version-task.yml`: a resolver command -prints the upstream version(s) as a **JSON object of `name -> version`**, written to a committed -state file at the **repo root beside `version.json`** (default `upstream-version.json`, since it -is a build-input version source, not GitHub-platform config, so it does not belong under -`.github/`), and opens a rolling App-signed bump PR per branch that the merge-bot auto-merges -(`merge-upstream-version`). The object carries one key for the common single-version case -(`{"version": "X"}`) or N keys for a wrapper that pins several upstream components (e.g. an image -plus a companion tool), and the build reads each component by key, and the bump PR's title/body -name only the keys that actually moved. Call it from a scheduled entry-point workflow and matrix -only the branches that ship the version (a CI-only version uses `["develop"]`). A merged bump -ships on the **next publish**, not immediately, which is the two-phase latency tradeoff. +# Release Build and Publish Mechanics + +Full detail for the "Publishing" rules in `SKILL.md`. Load this when adding or removing a release +target, wiring a new leaf build task, deciding where a build output belongs (a GitHub Release +asset, a package-registry push, an image push, a deploy), or setting up a wrapper repo that tracks +an upstream release, not for reading the release model's shape (the SKILL.md summary covers that). + +## Reusable-task parameter contract + +Every `build-*-task.yml` and `build-release-task.yml` takes `ref` (git ref to check out/version), +`branch` (logical branch driving config/tags/prerelease, where `main` => Release/`latest`/ +non-prerelease, else Debug/`develop`/prerelease), and where relevant `smoke`. +**Branch-derived config keys off `inputs.branch`**: each run builds one branch, and the top-level +publisher passes `branch: ${{ github.ref_name }}`, which the tasks forward and read as +`inputs.branch` (not `github.ref_name`) for config/tags/prerelease. `get-version-task.yml` takes a +`ref` so NBGV versions the right branch. + +## Per-target subsetting + +`build-release-task.yml` has per-target `enable_*` gates and self-contained leaf tasks, so a +project that drops a target deletes: its `build-<target>-task.yml`, the matching job plus +`github-release`'s `needs` entry in `build-release-task.yml`, its path-filter entry in +`test-pull-request.yml`, and (for PyPI) the `publish-pypi` job in `publish-release.yml`. CodeGen, +versioning, badge, merge-bot, and Dependabot 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` +plus `github-release` jobs inside `build-release-task.yml`, `get-version-task.yml`, +`build-datebadge-task.yml`, and the aggregator shape of `test-pull-request.yml`. Within +`test-pull-request.yml`, only the `changes -> smoke-build -> check-workflow-status` aggregator +wiring and the ruleset-bound job name are verbatim orchestration, while the `unit-test` job and +the `dorny/paths-filter` entries are owned/per-target. The **build** layer (the +`build-<target>-task.yml` leaf tasks) is what a derived project owns and replaces. + +The contract that keeps the seam clean: **a target contributes files to the GitHub release by +uploading a workflow artifact named `release-asset-<branch>-<target>`.** The `github-release` job +collects every `release-asset-<branch>-*` 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-<branch>-<target>` 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): the *list* of leaf jobs in +`build-release-task.yml`. Per the per-target subsetting rule above, delete the target jobs not +shipped and add the ones that are. `build-release-task.yml`'s `github-release` job is untouched, but the file +is not byte-identical because its `needs`/job list reflects the repo's own targets. Making that +list itself target-agnostic is the release-chain phase of `docs/reusable-workflows.md` in the +hub, where the orchestrator becomes a hub-hosted task and each target a composite-action hook, +and until that phase ships the list stays per repo. + +## Map your outputs to the right seam + +Pick by where each artifact *goes*, not by language: + +- **Files attached to the GitHub Release** (zips, binaries, packaged libraries): one leaf task per + output, each uploading `release-asset-<branch>-<name>`. A data-only repo (e.g. a symbol library) + has exactly one such task: validate -> `zip` -> upload `release-asset-<branch>-library`. It + deletes the nuget/pypi/executable/docker jobs and the `publish-pypi` job, keeps `github-release` + as-is. This is also where the .NET `build-executable-task` lives, and it is *not* a generic file + step but specifically `dotnet publish` of the console app, so replace it wholesale, don't adapt + it. +- **Package-registry pushes** (NuGet.org, PyPI): the leaf task both builds **and** publishes to + its registry. NuGet pushes from inside `build-nugetlibrary-task` (`dotnet nuget push + --skip-duplicate`) *and* also uploads a `release-asset-*` (.7z) for the GitHub release. PyPI is + split: `build-pypilibrary-task` only builds and uploads the `pypilibrary-build-<branch>` + artifact, and the separate `publish-pypi` job in `publish-release.yml` does the OIDC + Trusted-Publishing upload (`id-token: write` is granted only at that one entry point), and PyPI + contributes **no** `release-asset-*`. +- **Image-registry pushes** (Docker Hub): `build-docker-task` pushes multi-arch tags directly and + contributes **no** `release-asset-*`. The image tag is build-layer-owned, so drive it from + whatever version source fits (NBGV `SemVer2`, an upstream-release pin, or a per-image matrix). + To publish the Docker Hub repository overview, `publish-docker-readme-task.yml` pushes + `Docker/README.md` via `peter-evans/dockerhub-description` (single-repo by default, matrix per + image for multi-image repos), wired into `publish-release.yml` and gated to `main`. +- **Filesystem on a host the project owns** (a static site, a config tree): a deploy leaf builds + the tree and ships it over the repo's own transport, contributing **no** `release-asset-*`. It + is a **separate `workflow_dispatch`** from the release, so a redeploy of an unchanged commit + mints no tag, and its credentials come from a **per-environment GitHub Environment** rather than + the repository secret store. Its last step asserts what the host actually serves, the release id + and the environment, never that the transport exited zero. Retention at the destination is + bounded by a declared count, and one side is recorded as owning the prune: the deploy where its + credential can observe the destination, the host where that credential is deliberately + write-only. +- **Source-only / no build** (validate + tag + release): this seam does not apply. A source-only + repo carries **no** `build-release-task.yml` (its `appliesTo` excludes it), so there are no leaf + tasks and no `get-version`/`github-release`/`date-badge` jobs to curate. Its whole release is + the standalone `publish-release.yml` on `workflow_dispatch`: a `validate` job (the repo's + reusable validation task) gates a publish job that **inlines** NBGV for the tag and + `action-gh-release` for the release (tag, auto source archive, README, LICENSE). + +`get-version-task.yml` installs the .NET SDK only because NBGV needs the runtime to compute the +version/tag, which is heavyweight but expected even for a non-.NET repo, and acceptable as-is. + +## No-op republish guarantee + +A weekly/dispatch publish where NBGV `SemVer2` is **unchanged** (no new commit since the last +publish) re-pushes **nothing** to GitHub Releases (the `github-release` job's `release-exists` +check skips the create step), NuGet (`dotnet nuget push --skip-duplicate`), or PyPI +(`gh-action-pypi-publish` `skip-existing: true`), since all three key on the version string. +**Docker always re-pushes** by design: it picks up upstream base-image refreshes (e.g. +`ubuntu:rolling`) that aren't visible in the repo. Boundary: `version.json` has **no +`pathFilters`**, so *any* commit, including a CI/workflow-only or docs-only change, advances the +NBGV git height and therefore `SemVer2`, and the next publish *does* create a fresh release for it +even when the shipped binary is byte-identical. This is accepted NBGV behavior, and `pathFilters` +are intentionally not added. + +## Wrapper repos that track an upstream release + +A repo wrapping an upstream release uses `check-upstream-version-task.yml`: a resolver command +prints the upstream version(s) as a **JSON object of `name -> version`**, written to a committed +state file at the **repo root beside `version.json`** (default `upstream-version.json`, since it +is a build-input version source, not GitHub-platform config, so it does not belong under +`.github/`), and opens a rolling App-signed bump PR per branch that the merge-bot auto-merges +(`merge-upstream-version`). The object carries one key for the common single-version case +(`{"version": "X"}`) or N keys for a wrapper that pins several upstream components (e.g. an image +plus a companion tool), and the build reads each component by key, and the bump PR's title/body +name only the keys that actually moved. Call it from a scheduled entry-point workflow and matrix +only the branches that ship the version (a CI-only version uses `["develop"]`). A merged bump +ships on the **next publish**, not immediately, which is the two-phase latency tradeoff. diff --git a/.claude-plugin/fleet-skills/skills/pr-review-conduct/SKILL.md b/.claude-plugin/fleet-skills/skills/pr-review-conduct/SKILL.md index f5c56bcf..3e323cee 100644 --- a/.claude-plugin/fleet-skills/skills/pr-review-conduct/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/pr-review-conduct/SKILL.md @@ -1,146 +1,146 @@ ---- -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 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 "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 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 (GitHub Copilot's request/poll/ - reply API calls) live in .github/copilot-instructions.md's "GitHub Copilot Review Runbook," - this skill is the contract that runbook implements, not a replacement for it. ---- - -# PR Review Conduct - -## Why this exists - -`mergeStateStatus: CLEAN` reflects required status checks and any review thread the ruleset's -conversation-resolution requirement already tracks as resolved. It says nothing about whether the -review that resolved those threads actually covered the **current** head SHA, whether it read the -full diff rather than part of it, or whether a suppressed low-confidence finding, which never -opens a thread for the ruleset to see, was ever answered. A PR that looks done, green checks, no -visible comments, routinely still carries a finding nobody has answered. Treating "green" as -"mergeable" is the single most common way this loop gets skipped. - -## Merge Gate, check this before merging or enabling auto-merge - -**Do not merge, and do not enable auto-merge, unless ALL of these hold:** - -1. Required status checks are green, and where they are not, the reason is **read**, never - inferred. `BLOCKED` covers a failed check, a required check nothing is running, an unresolved - thread, and a missing approval alike, and the response differs by cause. -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. -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. -4. Nothing in the review was a shape the tooling could not read (an unrecognized heading, a moved - section, an unfamiliar coverage wording). An unrecognized shape blocks the gate on its own. - File an issue naming it and quoting the body, rather than guessing what the new wording - probably meant. -5. The maintainer has given **explicit** permission to merge. - -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. - -## Expected review loop - -1. Push changes to the PR branch. -2. Re-request a review for the **current head SHA**. Auto-trigger is unreliable, so request it - explicitly (mechanics in the Copilot runbook). The UI is a fallback only. -3. Wait for review activity on that head. A completed review raising **no findings** is a valid - terminal outcome, so don't re-trigger it or read silence as a missing review. A review whose - body says it declined to review is the one exception, and it is terminal the other way: - nothing follows it, and re-requesting the same head just repeats the decline. -4. Triage findings (see below). -5. Apply fixes or write a rationale for declines. -6. Reply to each thread and resolve what was addressed. -7. Re-run the loop after every fix push until no actionable finding remains. - -Drive to green, a review confirmed on the latest head SHA and every actionable finding closed, -then apply the Merge Gate above. **Never exit the loop early.** A round count is not a stopping -condition, and neither is patience running out. - -## Every finding ends in one of five outcomes - -1. **Real, so fix it.** Reply with the fixing commit SHA. For a finding on platform-specific code - (PowerShell, a macOS- or WSL-only path), "fixed" means executed on that platform, per - `agent-conduct` "Before Claiming Done": a fix reasoned out by analogy to a tested equivalent - elsewhere is not yet fixed, and the reply says so rather than claiming the SHA closes it. -2. **Not real, or real but structurally out of scope, so decline in the thread with evidence.** - Disprove a wrong finding with the command and its output, the code path that makes it - impossible, or the rule that governs it. A finding that is factually correct but not this - repo's to fix (a verbatim-fidelity manifest entry byte-locking the section, ownership that - sits elsewhere) declines the same way: name the boundary and cite what proves it. Either shape - closes the thread on its own evidence. An assertion ("this is fine") does not close a finding, - a decline needs evidence the reviewer itself could check. -3. **Real, fixable here, but deliberately left as is, a value call rather than a scope - boundary, so it is the maintainer's, not the agent's.** Reach for this only once outcome 2 is - ruled out, since a scope boundary declines on its own evidence and never needs this outcome at - all. State the finding and why the fix is unwanted, and get an explicit answer in the same - turn, before moving to other work. A plan to ask later is resolution by silence the moment - attention moves elsewhere. If the maintainer is not reachable right now, leave the thread open - and say so, rather than treating the intention to ask as the asking. -4. **Real and worth doing later, so file the issue first, then reply with its link.** A deferral - noted only in a thread is lost the moment the PR merges. -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. - -**A disposition decided on one PR does not carry to the next.** The same finding shape recurring -on a sibling repo or PR, even within one batch or one session, gets its own outcome: its own -evidence-backed decline (outcome 2) or its own explicit maintainer answer (outcome 3). A prior -instance's outcome is context for the new one, never a standing answer to reuse in its place. - -## Triaging findings - -**A low-confidence (suppressed) finding is not a low-value one.** Judge each against the code, -never against its confidence label. Classify before responding: - -- **Bug**, wrong behavior, missing coverage, a real code or doc divergence. Fix it. -- **Style or convention**. If the cited rule matches the existing tree, fix the code. If the rule - contradicts the tree or industry norm, **fix the rule, not the code**, and take it to the - maintainer (outcome 5) rather than bouncing the same code across rounds. -- **Architectural opinion**, a proposed redesign. Surface it with a recommendation, never apply - it unilaterally. - -## Answering a suppressed finding - -A suppressed finding has no thread and no resolved or unresolved state, so an answer needs to -carry its own context: quote the finding (with its `file:line` anchor and enough of the -reviewer's own words to identify it), give one bold verdict per finding (`Fixed in <SHA>`, -`Disproven`, or `No change needed`), state the `(N)` count the block gave so answers can be -checked against findings, and link the review round. **Read every round, not only the head.** A -suppressed finding does not retire when a later push supersedes it, it just stops showing up in a -head-scoped query while still unanswered. - -## Escalate to the maintainer when - -- A genuine design trade-off surfaces (fail-open vs. fail-closed, refactor scope). -- A finding keeps recurring. Bring the pattern and a recommended fix (rule change or code - change), don't keep silently re-declining it. -- A finding is judged real but should not be fixed. That decision is never the agent's alone. -- An architectural redesign is proposed rather than a bug fix. - -## Mechanics live elsewhere - -This skill is the provider-agnostic contract. For the actual GitHub API calls, requesting a -Copilot review, polling for it, matching the suppressed-findings heading (its wording has moved -more than once), verifying head-SHA and diff coverage, replying and resolving threads without a -hand-typed id, see `.github/copilot-instructions.md` "GitHub Copilot Review Runbook" in the repo -being worked in, and reach for `scripts/pr_review.py` (hub-hosted, invoked from a hub checkout) -before hand-rolling any of it. `status` reports coverage, threads, suppressed findings, and shapes -in one call, `wait` backs off in-process, and `reply` resolves a thread by matching the finding's -own words rather than a line number a fix push can move. +--- +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 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 "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 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 (GitHub Copilot's request/poll/ + reply API calls) live in .github/copilot-instructions.md's "GitHub Copilot Review Runbook," + this skill is the contract that runbook implements, not a replacement for it. +--- + +# PR Review Conduct + +## Why this exists + +`mergeStateStatus: CLEAN` reflects required status checks and any review thread the ruleset's +conversation-resolution requirement already tracks as resolved. It says nothing about whether the +review that resolved those threads actually covered the **current** head SHA, whether it read the +full diff rather than part of it, or whether a suppressed low-confidence finding, which never +opens a thread for the ruleset to see, was ever answered. A PR that looks done, green checks, no +visible comments, routinely still carries a finding nobody has answered. Treating "green" as +"mergeable" is the single most common way this loop gets skipped. + +## Merge Gate, check this before merging or enabling auto-merge + +**Do not merge, and do not enable auto-merge, unless ALL of these hold:** + +1. Required status checks are green, and where they are not, the reason is **read**, never + inferred. `BLOCKED` covers a failed check, a required check nothing is running, an unresolved + thread, and a missing approval alike, and the response differs by cause. +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. +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. +4. Nothing in the review was a shape the tooling could not read (an unrecognized heading, a moved + section, an unfamiliar coverage wording). An unrecognized shape blocks the gate on its own. + File an issue naming it and quoting the body, rather than guessing what the new wording + probably meant. +5. The maintainer has given **explicit** permission to merge. + +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. + +## Expected review loop + +1. Push changes to the PR branch. +2. Re-request a review for the **current head SHA**. Auto-trigger is unreliable, so request it + explicitly (mechanics in the Copilot runbook). The UI is a fallback only. +3. Wait for review activity on that head. A completed review raising **no findings** is a valid + terminal outcome, so don't re-trigger it or read silence as a missing review. A review whose + body says it declined to review is the one exception, and it is terminal the other way: + nothing follows it, and re-requesting the same head just repeats the decline. +4. Triage findings (see below). +5. Apply fixes or write a rationale for declines. +6. Reply to each thread and resolve what was addressed. +7. Re-run the loop after every fix push until no actionable finding remains. + +Drive to green, a review confirmed on the latest head SHA and every actionable finding closed, +then apply the Merge Gate above. **Never exit the loop early.** A round count is not a stopping +condition, and neither is patience running out. + +## Every finding ends in one of five outcomes + +1. **Real, so fix it.** Reply with the fixing commit SHA. For a finding on platform-specific code + (PowerShell, a macOS- or WSL-only path), "fixed" means executed on that platform, per + `agent-conduct` "Before Claiming Done": a fix reasoned out by analogy to a tested equivalent + elsewhere is not yet fixed, and the reply says so rather than claiming the SHA closes it. +2. **Not real, or real but structurally out of scope, so decline in the thread with evidence.** + Disprove a wrong finding with the command and its output, the code path that makes it + impossible, or the rule that governs it. A finding that is factually correct but not this + repo's to fix (a verbatim-fidelity manifest entry byte-locking the section, ownership that + sits elsewhere) declines the same way: name the boundary and cite what proves it. Either shape + closes the thread on its own evidence. An assertion ("this is fine") does not close a finding, + a decline needs evidence the reviewer itself could check. +3. **Real, fixable here, but deliberately left as is, a value call rather than a scope + boundary, so it is the maintainer's, not the agent's.** Reach for this only once outcome 2 is + ruled out, since a scope boundary declines on its own evidence and never needs this outcome at + all. State the finding and why the fix is unwanted, and get an explicit answer in the same + turn, before moving to other work. A plan to ask later is resolution by silence the moment + attention moves elsewhere. If the maintainer is not reachable right now, leave the thread open + and say so, rather than treating the intention to ask as the asking. +4. **Real and worth doing later, so file the issue first, then reply with its link.** A deferral + noted only in a thread is lost the moment the PR merges. +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. + +**A disposition decided on one PR does not carry to the next.** The same finding shape recurring +on a sibling repo or PR, even within one batch or one session, gets its own outcome: its own +evidence-backed decline (outcome 2) or its own explicit maintainer answer (outcome 3). A prior +instance's outcome is context for the new one, never a standing answer to reuse in its place. + +## Triaging findings + +**A low-confidence (suppressed) finding is not a low-value one.** Judge each against the code, +never against its confidence label. Classify before responding: + +- **Bug**, wrong behavior, missing coverage, a real code or doc divergence. Fix it. +- **Style or convention**. If the cited rule matches the existing tree, fix the code. If the rule + contradicts the tree or industry norm, **fix the rule, not the code**, and take it to the + maintainer (outcome 5) rather than bouncing the same code across rounds. +- **Architectural opinion**, a proposed redesign. Surface it with a recommendation, never apply + it unilaterally. + +## Answering a suppressed finding + +A suppressed finding has no thread and no resolved or unresolved state, so an answer needs to +carry its own context: quote the finding (with its `file:line` anchor and enough of the +reviewer's own words to identify it), give one bold verdict per finding (`Fixed in <SHA>`, +`Disproven`, or `No change needed`), state the `(N)` count the block gave so answers can be +checked against findings, and link the review round. **Read every round, not only the head.** A +suppressed finding does not retire when a later push supersedes it, it just stops showing up in a +head-scoped query while still unanswered. + +## Escalate to the maintainer when + +- A genuine design trade-off surfaces (fail-open vs. fail-closed, refactor scope). +- A finding keeps recurring. Bring the pattern and a recommended fix (rule change or code + change), don't keep silently re-declining it. +- A finding is judged real but should not be fixed. That decision is never the agent's alone. +- An architectural redesign is proposed rather than a bug fix. + +## Mechanics live elsewhere + +This skill is the provider-agnostic contract. For the actual GitHub API calls, requesting a +Copilot review, polling for it, matching the suppressed-findings heading (its wording has moved +more than once), verifying head-SHA and diff coverage, replying and resolving threads without a +hand-typed id, see `.github/copilot-instructions.md` "GitHub Copilot Review Runbook" in the repo +being worked in, and reach for `scripts/pr_review.py` (hub-hosted, invoked from a hub checkout) +before hand-rolling any of it. `status` reports coverage, threads, suppressed findings, and shapes +in one call, `wait` backs off in-process, and `reply` resolves a thread by matching the finding's +own words rather than a line number a fix push can move. diff --git a/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md b/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md index 9868071e..a5a262d4 100644 --- a/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md @@ -1,287 +1,286 @@ ---- -name: python-codestyle -description: >- - Governs Python code style for ptr727/ProjectTemplate fleet repos: 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. Use this whenever writing, reviewing, or editing a .py file, a - pyproject.toml, or a uv.lock, whenever deciding whether a Python subtree is a shippable project - or a lint-only scripts tree, whenever choosing pyright versus mypy for a repo's CI gate, or - whenever writing or reviewing a pytest test. Triggers even when the task looks like a small - local fix ("just add a helper function", "silence this lint warning", "add a dependency"), - because the profile split, the ruff-is-authoritative rule, and the ban on backward-compat - shims or impossible-case error handling are each easy to violate one file at a time. Applies - only to a repo's Python side, a repo with no Python has no use for this Skill. ---- - -# Python Codestyle - -## Why this exists - -This is the Python-specific half of the fleet's code style guide, kept in one place instead of -re-derived per repo or per session. CODESTYLE.md's General section still owns the rules every -language shares (clean-compile verification as a concept, the suppression-scope order, tooling -casing in prose), this Skill is everything specific to a Python project on top of that: the two -profiles, the toolchain, layout, and the language-level conventions. - -## Adapt before propagating - -The rules below describe the default Python profile: a package that publishes to PyPI, -type-checked by pyright in strict mode, dependencies in `[dependency-groups]`. A derived repo -often differs, and when it does, adapt these fields to match the repo's actual toolchain rather -than copying verbatim (a verbatim copy that misdescribes the repo is inaccurate and gets rejected -in review). The axes that commonly vary per repo: - -- **Type checker in CI**: pyright strict, mypy in CI with pyright editor-only (Pylance), or both. - Whichever runs in CI is the one the clean-compile and the CI gate invoke. -- **Dependency declaration**: `[dependency-groups]`, or PEP 621 `[project.optional-dependencies]` - (dev tools installed with `uv sync --extra <group>`). -- **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 - `<Repo>.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, - and the canonical task definitions it is written against are the hub `vscode-tasks-python.json` - snippet, which resolves the same way from every repo. - -## Two profiles - -A repo's Python is one of two shapes, declared as the `build` or `lint-only` profile and validated -against the `pyproject.toml` shape. Most of this Skill (uv project, `uv.lock`, `uv run`, src -layout, pytest coverage) describes the Project shape (the `build` profile). The two differ by -whether the Python has third-party runtime dependencies, which shows up structurally in -`pyproject.toml`, so the fleet's audit reads the shape there: - -- **Project** (the `build` profile): the Python has third-party runtime dependencies, or is the - repo's deliverable. It is a PEP 621 uv project: `[project]` with `dependencies` (dev tools in - `[project.optional-dependencies]` or `[dependency-groups]`), a `[build-system]`, and a committed - `uv.lock` (pinned LF, per GOVERNANCE.md's "Line Endings" section). CI runs `uv sync --frozen` + - `uv run <tool>`, so the lockfile pins tool versions. -- **Scripts** (the `lint-only` profile): stdlib-only utility scripts embedded in a non-Python repo - (e.g. a Python tooling subtree of a `csharp` app). Run the tools with `uvx` (no project install, - no lockfile): the `pyproject.toml` carries only tool config (`[tool.ruff]`, `[tool.mypy]`, and - an optional `[tool.pyright]` editor block), with no `[project]`, no `[build-system]`, and no - `uv.lock` (that metadata would misrepresent it as a shippable package). mypy is the type-check - gate (there is no first-party package for pyright strict to anchor on), and a `[tool.pyright]` - block in standard mode keeps Pylance quiet in the editor, the same mypy-gate/pyright-editor - split the build profile uses. There is no lockfile, and a `uvx <tool>@<ver>` pin in a `run:` - step is not something Dependabot tracks, so CI runs `uvx ruff@latest` / `uvx mypy@latest` rather - than a manual pin that would silently go stale. The fleet rule is to pin only what Dependabot - auto-updates (SHA-pinned actions, package deps) and otherwise run latest, so the VS Code tasks, - README, and CI all run the unpinned latest here. `.py` files follow the repo's line-ending - default (CRLF in a CRLF-default repo, and a shebang-executed script is LF-pinned by path, per - GOVERNANCE.md's "Line Endings" section). There is no pytest suite, and `unittest` is the runner - instead. A script that carries a gate still earns tests, written with the standard library's - `unittest` so they run under bare `python3` with nothing installed, as `test_<script>.py` under - a `tests/` directory beside the scripts it exercises (`<scripts-dir>/tests/`), kept apart so a - test never reads as a tool. Within the scripts directory the name carries the kind: a gate that - checks and exits non-zero on a finding takes a `_lint` or `_gate` suffix, and a utility that - does work takes none. Any repo carrying Python carries the Python tooling in CI, coverage - included, this profile too: `uvx ruff@latest check`, `uvx ruff@latest format --check`, - `uvx mypy@latest`, and the unittest suite - under `uvx coverage@latest run -m unittest discover -s <scripts-dir>/tests` with - `coverage report`, informational with no threshold adopted. A co-present `csharp` type still - carries `codecov.yml` for its own tests. - -## Toolchain - -| Tool | Role | Config | -|---|---|---| -| [uv][uv-link] | env, deps, build, publish (build/publish only where the repo ships a package) | `pyproject.toml` `[dependency-groups]` or `[project.optional-dependencies]`, `uv.lock` | -| [hatchling][latest-link] | build backend (published packages) | `pyproject.toml` `[build-system]` | -| [ruff][ruff-link] | lint + format + import sort | `pyproject.toml` `[tool.ruff]` | -| [pyright][pyright-link] | type checker (the default, a strict baseline) | `pyproject.toml` `[tool.pyright]` | -| [mypy][mypy-link] | additional/alternate type checker (optional, the CI checker in a mypy-in-CI repo, required for Home Assistant) | `pyproject.toml` `[tool.mypy]` (or per home-assistant/core) | -| [pytest][docs-link] | test runner | `pyproject.toml` `[tool.pytest.ini_options]` | - -**Type checking targets strongly typed, deterministic code.** pyright in strict mode is the -default baseline on first-party code (a repo may instead run mypy in CI and keep pyright -editor-only via Pylance, per the next paragraph): `[tool.pyright]` `strict = ["src"]`, or the -integration package for a Home Assistant repo, with tests run in standard mode. pyright is the -anchor because Pylance embeds it, so the editor and the CLI/CI (`uv run pyright`) run the same -engine and never disagree. The standalone `ms-pyright.pyright` extension stays in -`unwantedRecommendations` because Pylance covers it. Relax strictness on third-party code only -when a dependency has no usable types and no alternative (e.g. `pandas`): a targeted, commented -`# pyright: ignore[...]` or a scoped `[tool.pyright]` override, never a blanket relaxation. - -**mypy is allowed, and required where the ecosystem demands it, it is not banned.** Running more -than one checker is normal when each serves a purpose (the .NET side pairs CSharpier and -`dotnet format` the same way), and pyright's inference and mypy's plugin ecosystem (e.g. -`pydantic.mypy`) catch different classes of error. A Home Assistant integration runs -`mypy --strict` because the platinum `strict-typing` quality-scale tier requires it, and a -pydantic-heavy library may opt in for the plugin. When a repo uses mypy it runs in CI and the -editor (the `ms-python.mypy-type-checker` extension) so the two stay consistent, and its mypy -command joins the clean-compile. A repo with no such need stays pyright-only, which is lighter and -inherently consistent. - -## Local development loop - -From inside the Python project directory: - -```sh -uv sync # creates .venv, installs deps + dev group -uv run ruff format # auto-format -uv run ruff check --fix # auto-fix lint -uv run ruff check # verify lint clean -uv run ruff format --check # verify format clean -uv run pyright # verify types -uv run pytest # run tests -uv build # produce wheel + sdist in ./dist (published packages only) -``` - -The Python clean-compile is `uv run ruff format` + `uv run ruff check` + the repo's type checker: -`uv run pyright`, or `uv run mypy src` where mypy is the CI checker, or both where the repo runs -both (see Type checking above). Run it, plus `uv run pytest`, before committing. These are -documented commands, and an optional VS Code tasks mirror (all `type: process`, no `&&` shell -chaining, so it runs the same on any task shell) is in the hub `vscode-tasks-python.json` snippet. -CI runs the same clean-compile commands as the authoritative backstop. Git hooks are opt-in, so -wire `pre-commit` for `ruff` and the type checker yourself if you want local enforcement. - -## Layout - -`src` layout, which keeps the package out of the repo root and prevents accidental imports of -unbuilt code: - -```text -<python-project>/ - pyproject.toml - README.md - uv.lock # committed for reproducible CI - src/ - <package_name>/ - __init__.py - _version.py # published packages; a source-only repo uses a static version instead - <modules>.py - tests/ - __init__.py - test_<module>.py -``` - -## Code style - -### Formatting and linting - -- **`ruff format` is authoritative.** Don't argue with the formatter, and if it reformats your - code, that's the final form. Configure (line length, target version) in `pyproject.toml` - `[tool.ruff]`, not via inline `# fmt:` directives. -- **Run `ruff check --fix` before committing.** Most ruff lint rules have safe autofixes, let the - tool handle them. The configured rule families are listed under `[tool.ruff.lint]` `select`. Add - new rule families project-wide rather than scattering inline `# noqa` markers. -- **`# noqa` is a last resort.** When you must use one, scope it narrowly (`# noqa: E501`, not - bare `# noqa`) and add a short comment on the same line explaining why. False-positive patterns - that recur across the codebase belong in `[tool.ruff.lint]` `ignore` or per-file - `[tool.ruff.lint.per-file-ignores]`, with a comment. Porting an existing codebase is not a - license to add `ignore` / `per-file-ignores` blocks to mute newly surfaced lint. Fix it. - -### Comments - -- **Inline `#` comments**: keep tight and local. One line is preferred, but multi-line is fine - when you need to document a non-obvious implementation constraint, a local trade-off, or - coupling that future edits could easily break. Keep that rationale next to the affected block so - the reviewer/maintainer sees it at edit-time. -- **Don't explain what the code does.** Well-named identifiers handle that. Don't reference the - current task ("added for X", "used by Y"), which belongs in the PR description. - -### Docstrings - -- Follow [PEP 257][pep-0257-link]. Focus docstrings primarily on the behavior contract (what - callers and tests can rely on), public semantics, and edge-case expectations. - Implementation-local rationale belongs in inline `#` comments, not docstrings. -- A short one-liner is fine for trivial functions and tests with self-documenting names. -- For non-trivial behavior (non-obvious test scenarios, contracts a test pins, edge cases callers - must know about, design trade-offs that are load-bearing for future maintainers), write a - one-line summary, blank line, then a details paragraph. Multi-paragraph docstrings are fine when - the contract earns it. -- Design notes belong in the code (docstrings or inline comments). They do NOT belong in - `HISTORY.md`, which is end-user release notes, not a design log. - -### Type hints - -- **All public APIs are typed.** The repo's configured type checker runs on `src/` (pyright strict - via `[tool.pyright]` `strict = ["src"]`, or mypy where that is the CI checker), and tests run in - the checker's looser/standard mode. -- **Use modern syntax**: `list[int]` not `List[int]`, `dict[str, X]` not `Dict[str, X]`, - `X | None` not `Optional[X]`, `from __future__ import annotations` only when needed for forward - references. -- **Don't add `# type: ignore` to silence pyright errors without a comment** explaining the - constraint. If a recurring false positive needs suppression, configure it project-wide in - `[tool.pyright]`. A new port doesn't change this, fix freshly surfaced type errors rather than - muting them. - -### Naming - -- `snake_case` for functions, methods, variables, modules, package directories. -- `PascalCase` for classes, type aliases, type vars, enum members. -- `UPPER_SNAKE_CASE` for module-level constants. -- Single leading underscore for module-private, double leading underscore for name-mangled (rare, - and usually means rethink the design). - -### Imports - -- **Let ruff sort imports.** `[tool.ruff.lint]` `select` includes the `I` rule family - (isort-equivalent). Don't hand-sort. -- Standard library first, then third-party, then first-party (the project itself), each block - separated by a blank line, which ruff enforces automatically. -- Avoid wildcard imports (`from x import *`) outside `__init__.py` re-exports. - -### Patterns to avoid - -- **Don't add backward-compat shims, `# removed` markers, or rename-to-`_` for unused vars**, just - delete. Git history is the audit trail. -- **Don't add error handling for impossible cases.** Trust internal code, and validate only at - boundaries (user input, parsed config, external APIs). -- **Don't use exceptions for expected control flow.** Exceptions are for unexpected states. -- **Don't suppress errors silently** (`except Exception: pass`). Either handle the specific - exception and document why it's safe, or let it propagate. - -## Tests - -- `pytest` with the configuration in `[tool.pytest.ini_options]`. Default invocation: - `uv run pytest`. -- One test file per module under test, named `test_<module>.py`. -- Test functions named `test_<scenario>_<expected_behavior>`, descriptive and not numbered. -- Use fixtures (defined in `conftest.py` for shared ones, or per-test for narrowly-scoped) instead - of setup/teardown methods. -- **Avoid mocking when fakes work.** Hand-rolled fakes that implement the protocol you depend on - are usually clearer and break less than `unittest.mock` magic. -- **Test edge cases that the docstring promises**, not implementation details. If the test breaks - when you refactor without changing behavior, the test is asserting on an implementation detail. - -## Versioning - -**Published packages.** `_version.py` ships with `__version__ = "0.0.0"` as a placeholder. Until -you wire `_version.py` to something that increments (the usual options are `hatch-vcs`, a -version.json bridge, or manual bumps), no new PyPI versions will land, and publishing with -`skip-existing: true` keeps a stuck placeholder version from failing the run. - -**Source-only repos** (no PyPI publish, with a source-release on dispatch or no release at all) do -not need `_version.py`: keep a static `version` in `pyproject.toml` `[project]`, or let the -release pipeline's version source (e.g. NBGV plus `version.json`) own the tag. There is no publish -step to guard, so `skip-existing` does not apply. - -## Linter cleanliness - -Before pushing or opening a PR: - -- VS Code's Problems pane should be quiet for the files you touched. The relevant linters are ruff - (via the `charliermarsh.ruff` extension) and pyright (via the `ms-python.python` extension's - bundled Pylance). -- The CI gate is `uv run ruff check`, `uv run ruff format --check`, the repo's type checker - (`uv run pyright` or `uv run mypy src`), and `uv run pytest`, the same commands as the local - loop above, run from the Python project directory (invoked as separate steps, not `&&`-chained, - so the runner shell is irrelevant). -- Markdown in this directory follows CODESTYLE.md's repo-wide Markdown and Spelling rules, - packaged as the `comment-and-doc-style` Skill. - -<!-- External --> - -[docs-link]: https://docs.pytest.org/ -[latest-link]: https://hatch.pypa.io/latest/ -[mypy-link]: https://mypy-lang.org/ -[pep-0257-link]: https://peps.python.org/pep-0257/ -[pyright-link]: https://microsoft.github.io/pyright/ -[ruff-link]: https://docs.astral.sh/ruff/ -[uv-link]: https://docs.astral.sh/uv/ +--- +name: python-codestyle +description: >- + Governs Python code style for ptr727/ProjectTemplate fleet repos: 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. Use this whenever writing, reviewing, or editing a .py file, a + pyproject.toml, or a uv.lock, whenever deciding whether a Python subtree is a shippable project + or a lint-only scripts tree, whenever choosing pyright versus mypy for a repo's CI gate, or + whenever writing or reviewing a pytest test. Triggers even when the task looks like a small + local fix ("just add a helper function", "silence this lint warning", "add a dependency"), + because the profile split, the ruff-is-authoritative rule, and the ban on backward-compat + shims or impossible-case error handling are each easy to violate one file at a time. Applies + only to a repo's Python side, a repo with no Python has no use for this Skill. +--- + +# Python Codestyle + +## Why this exists + +This is the Python-specific half of the fleet's code style guide, kept in one place instead of +re-derived per repo or per session. CODESTYLE.md's General section still owns the rules every +language shares (clean-compile verification as a concept, the suppression-scope order, tooling +casing in prose), this Skill is everything specific to a Python project on top of that: the two +profiles, the toolchain, layout, and the language-level conventions. + +## Adapt before propagating + +The rules below describe the default Python profile: a package that publishes to PyPI, +type-checked by pyright in strict mode, dependencies in `[dependency-groups]`. A derived repo +often differs, and when it does, adapt these fields to match the repo's actual toolchain rather +than copying verbatim (a verbatim copy that misdescribes the repo is inaccurate and gets rejected +in review). The axes that commonly vary per repo: + +- **Type checker in CI**: pyright strict, mypy in CI with pyright editor-only (Pylance), or both. + Whichever runs in CI is the one the clean-compile and the CI gate invoke. +- **Dependency declaration**: `[dependency-groups]`, or PEP 621 `[project.optional-dependencies]` + (dev tools installed with `uv sync --extra <group>`). +- **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 + `<Repo>.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, + and the canonical task definitions it is written against are the hub `vscode-tasks-python.json` + snippet, which resolves the same way from every repo. + +## Two profiles + +A repo's Python is one of two shapes, declared as the `build` or `lint-only` profile and validated +against the `pyproject.toml` shape. Most of this Skill (uv project, `uv.lock`, `uv run`, src +layout, pytest coverage) describes the Project shape (the `build` profile). The two differ by +whether the Python has third-party runtime dependencies, which shows up structurally in +`pyproject.toml`, so the fleet's audit reads the shape there: + +- **Project** (the `build` profile): the Python has third-party runtime dependencies, or is the + repo's deliverable. It is a PEP 621 uv project: `[project]` with `dependencies` (dev tools in + `[project.optional-dependencies]` or `[dependency-groups]`), a `[build-system]`, and a committed + `uv.lock` (pinned LF, per GOVERNANCE.md's "Line Endings" section). CI runs `uv sync --frozen` + + `uv run <tool>`, so the lockfile pins tool versions. +- **Scripts** (the `lint-only` profile): stdlib-only utility scripts embedded in a non-Python repo + (e.g. a Python tooling subtree of a `csharp` app). Run the tools with `uvx` (no project install, + no lockfile): the `pyproject.toml` carries only tool config (`[tool.ruff]`, `[tool.mypy]`, and + an optional `[tool.pyright]` editor block), with no `[project]`, no `[build-system]`, and no + `uv.lock` (that metadata would misrepresent it as a shippable package). mypy is the type-check + gate (there is no first-party package for pyright strict to anchor on), and a `[tool.pyright]` + block in standard mode keeps Pylance quiet in the editor, the same mypy-gate/pyright-editor + split the build profile uses. There is no lockfile, and a `uvx <tool>@<ver>` pin in a `run:` + step is not something Dependabot tracks, so CI runs `uvx ruff@latest` / `uvx mypy@latest` rather + than a manual pin that would silently go stale. The fleet rule is to pin only what Dependabot + auto-updates (SHA-pinned actions, package deps) and otherwise run latest, so the VS Code tasks, + README, and CI all run the unpinned latest here. `.py` files follow the repo's LF line-ending + default (per GOVERNANCE.md's "Line Endings" section). There is no pytest suite, and `unittest` is the runner + instead. A script that carries a gate still earns tests, written with the standard library's + `unittest` so they run under bare `python3` with nothing installed, as `test_<script>.py` under + a `tests/` directory beside the scripts it exercises (`<scripts-dir>/tests/`), kept apart so a + test never reads as a tool. Within the scripts directory the name carries the kind: a gate that + checks and exits non-zero on a finding takes a `_lint` or `_gate` suffix, and a utility that + does work takes none. Any repo carrying Python carries the Python tooling in CI, coverage + included, this profile too: `uvx ruff@latest check`, `uvx ruff@latest format --check`, + `uvx mypy@latest`, and the unittest suite + under `uvx coverage@latest run -m unittest discover -s <scripts-dir>/tests` with + `coverage report`, informational with no threshold adopted. A co-present `csharp` type still + carries `codecov.yml` for its own tests. + +## Toolchain + +| Tool | Role | Config | +|---|---|---| +| [uv][uv-link] | env, deps, build, publish (build/publish only where the repo ships a package) | `pyproject.toml` `[dependency-groups]` or `[project.optional-dependencies]`, `uv.lock` | +| [hatchling][latest-link] | build backend (published packages) | `pyproject.toml` `[build-system]` | +| [ruff][ruff-link] | lint + format + import sort | `pyproject.toml` `[tool.ruff]` | +| [pyright][pyright-link] | type checker (the default, a strict baseline) | `pyproject.toml` `[tool.pyright]` | +| [mypy][mypy-link] | additional/alternate type checker (optional, the CI checker in a mypy-in-CI repo, required for Home Assistant) | `pyproject.toml` `[tool.mypy]` (or per home-assistant/core) | +| [pytest][docs-link] | test runner | `pyproject.toml` `[tool.pytest.ini_options]` | + +**Type checking targets strongly typed, deterministic code.** pyright in strict mode is the +default baseline on first-party code (a repo may instead run mypy in CI and keep pyright +editor-only via Pylance, per the next paragraph): `[tool.pyright]` `strict = ["src"]`, or the +integration package for a Home Assistant repo, with tests run in standard mode. pyright is the +anchor because Pylance embeds it, so the editor and the CLI/CI (`uv run pyright`) run the same +engine and never disagree. The standalone `ms-pyright.pyright` extension stays in +`unwantedRecommendations` because Pylance covers it. Relax strictness on third-party code only +when a dependency has no usable types and no alternative (e.g. `pandas`): a targeted, commented +`# pyright: ignore[...]` or a scoped `[tool.pyright]` override, never a blanket relaxation. + +**mypy is allowed, and required where the ecosystem demands it, it is not banned.** Running more +than one checker is normal when each serves a purpose (the .NET side pairs CSharpier and +`dotnet format` the same way), and pyright's inference and mypy's plugin ecosystem (e.g. +`pydantic.mypy`) catch different classes of error. A Home Assistant integration runs +`mypy --strict` because the platinum `strict-typing` quality-scale tier requires it, and a +pydantic-heavy library may opt in for the plugin. When a repo uses mypy it runs in CI and the +editor (the `ms-python.mypy-type-checker` extension) so the two stay consistent, and its mypy +command joins the clean-compile. A repo with no such need stays pyright-only, which is lighter and +inherently consistent. + +## Local development loop + +From inside the Python project directory: + +```sh +uv sync # creates .venv, installs deps + dev group +uv run ruff format # auto-format +uv run ruff check --fix # auto-fix lint +uv run ruff check # verify lint clean +uv run ruff format --check # verify format clean +uv run pyright # verify types +uv run pytest # run tests +uv build # produce wheel + sdist in ./dist (published packages only) +``` + +The Python clean-compile is `uv run ruff format` + `uv run ruff check` + the repo's type checker: +`uv run pyright`, or `uv run mypy src` where mypy is the CI checker, or both where the repo runs +both (see Type checking above). Run it, plus `uv run pytest`, before committing. These are +documented commands, and an optional VS Code tasks mirror (all `type: process`, no `&&` shell +chaining, so it runs the same on any task shell) is in the hub `vscode-tasks-python.json` snippet. +CI runs the same clean-compile commands as the authoritative backstop. Git hooks are opt-in, so +wire `pre-commit` for `ruff` and the type checker yourself if you want local enforcement. + +## Layout + +`src` layout, which keeps the package out of the repo root and prevents accidental imports of +unbuilt code: + +```text +<python-project>/ + pyproject.toml + README.md + uv.lock # committed for reproducible CI + src/ + <package_name>/ + __init__.py + _version.py # published packages; a source-only repo uses a static version instead + <modules>.py + tests/ + __init__.py + test_<module>.py +``` + +## Code style + +### Formatting and linting + +- **`ruff format` is authoritative.** Don't argue with the formatter, and if it reformats your + code, that's the final form. Configure (line length, target version) in `pyproject.toml` + `[tool.ruff]`, not via inline `# fmt:` directives. +- **Run `ruff check --fix` before committing.** Most ruff lint rules have safe autofixes, let the + tool handle them. The configured rule families are listed under `[tool.ruff.lint]` `select`. Add + new rule families project-wide rather than scattering inline `# noqa` markers. +- **`# noqa` is a last resort.** When you must use one, scope it narrowly (`# noqa: E501`, not + bare `# noqa`) and add a short comment on the same line explaining why. False-positive patterns + that recur across the codebase belong in `[tool.ruff.lint]` `ignore` or per-file + `[tool.ruff.lint.per-file-ignores]`, with a comment. Porting an existing codebase is not a + license to add `ignore` / `per-file-ignores` blocks to mute newly surfaced lint. Fix it. + +### Comments + +- **Inline `#` comments**: keep tight and local. One line is preferred, but multi-line is fine + when you need to document a non-obvious implementation constraint, a local trade-off, or + coupling that future edits could easily break. Keep that rationale next to the affected block so + the reviewer/maintainer sees it at edit-time. +- **Don't explain what the code does.** Well-named identifiers handle that. Don't reference the + current task ("added for X", "used by Y"), which belongs in the PR description. + +### Docstrings + +- Follow [PEP 257][pep-0257-link]. Focus docstrings primarily on the behavior contract (what + callers and tests can rely on), public semantics, and edge-case expectations. + Implementation-local rationale belongs in inline `#` comments, not docstrings. +- A short one-liner is fine for trivial functions and tests with self-documenting names. +- For non-trivial behavior (non-obvious test scenarios, contracts a test pins, edge cases callers + must know about, design trade-offs that are load-bearing for future maintainers), write a + one-line summary, blank line, then a details paragraph. Multi-paragraph docstrings are fine when + the contract earns it. +- Design notes belong in the code (docstrings or inline comments). They do NOT belong in + `HISTORY.md`, which is end-user release notes, not a design log. + +### Type hints + +- **All public APIs are typed.** The repo's configured type checker runs on `src/` (pyright strict + via `[tool.pyright]` `strict = ["src"]`, or mypy where that is the CI checker), and tests run in + the checker's looser/standard mode. +- **Use modern syntax**: `list[int]` not `List[int]`, `dict[str, X]` not `Dict[str, X]`, + `X | None` not `Optional[X]`, `from __future__ import annotations` only when needed for forward + references. +- **Don't add `# type: ignore` to silence pyright errors without a comment** explaining the + constraint. If a recurring false positive needs suppression, configure it project-wide in + `[tool.pyright]`. A new port doesn't change this, fix freshly surfaced type errors rather than + muting them. + +### Naming + +- `snake_case` for functions, methods, variables, modules, package directories. +- `PascalCase` for classes, type aliases, type vars, enum members. +- `UPPER_SNAKE_CASE` for module-level constants. +- Single leading underscore for module-private, double leading underscore for name-mangled (rare, + and usually means rethink the design). + +### Imports + +- **Let ruff sort imports.** `[tool.ruff.lint]` `select` includes the `I` rule family + (isort-equivalent). Don't hand-sort. +- Standard library first, then third-party, then first-party (the project itself), each block + separated by a blank line, which ruff enforces automatically. +- Avoid wildcard imports (`from x import *`) outside `__init__.py` re-exports. + +### Patterns to avoid + +- **Don't add backward-compat shims, `# removed` markers, or rename-to-`_` for unused vars**, just + delete. Git history is the audit trail. +- **Don't add error handling for impossible cases.** Trust internal code, and validate only at + boundaries (user input, parsed config, external APIs). +- **Don't use exceptions for expected control flow.** Exceptions are for unexpected states. +- **Don't suppress errors silently** (`except Exception: pass`). Either handle the specific + exception and document why it's safe, or let it propagate. + +## Tests + +- `pytest` with the configuration in `[tool.pytest.ini_options]`. Default invocation: + `uv run pytest`. +- One test file per module under test, named `test_<module>.py`. +- Test functions named `test_<scenario>_<expected_behavior>`, descriptive and not numbered. +- Use fixtures (defined in `conftest.py` for shared ones, or per-test for narrowly-scoped) instead + of setup/teardown methods. +- **Avoid mocking when fakes work.** Hand-rolled fakes that implement the protocol you depend on + are usually clearer and break less than `unittest.mock` magic. +- **Test edge cases that the docstring promises**, not implementation details. If the test breaks + when you refactor without changing behavior, the test is asserting on an implementation detail. + +## Versioning + +**Published packages.** `_version.py` ships with `__version__ = "0.0.0"` as a placeholder. Until +you wire `_version.py` to something that increments (the usual options are `hatch-vcs`, a +version.json bridge, or manual bumps), no new PyPI versions will land, and publishing with +`skip-existing: true` keeps a stuck placeholder version from failing the run. + +**Source-only repos** (no PyPI publish, with a source-release on dispatch or no release at all) do +not need `_version.py`: keep a static `version` in `pyproject.toml` `[project]`, or let the +release pipeline's version source (e.g. NBGV plus `version.json`) own the tag. There is no publish +step to guard, so `skip-existing` does not apply. + +## Linter cleanliness + +Before pushing or opening a PR: + +- VS Code's Problems pane should be quiet for the files you touched. The relevant linters are ruff + (via the `charliermarsh.ruff` extension) and pyright (via the `ms-python.python` extension's + bundled Pylance). +- The CI gate is `uv run ruff check`, `uv run ruff format --check`, the repo's type checker + (`uv run pyright` or `uv run mypy src`), and `uv run pytest`, the same commands as the local + loop above, run from the Python project directory (invoked as separate steps, not `&&`-chained, + so the runner shell is irrelevant). +- Markdown in this directory follows CODESTYLE.md's repo-wide Markdown and Spelling rules, + packaged as the `comment-and-doc-style` Skill. + +<!-- External --> + +[docs-link]: https://docs.pytest.org/ +[latest-link]: https://hatch.pypa.io/latest/ +[mypy-link]: https://mypy-lang.org/ +[pep-0257-link]: https://peps.python.org/pep-0257/ +[pyright-link]: https://microsoft.github.io/pyright/ +[ruff-link]: https://docs.astral.sh/ruff/ +[uv-link]: https://docs.astral.sh/uv/ diff --git a/.claude-plugin/fleet-skills/skills/repo-worktree/SKILL.md b/.claude-plugin/fleet-skills/skills/repo-worktree/SKILL.md index c3769aab..82bf9d30 100644 --- a/.claude-plugin/fleet-skills/skills/repo-worktree/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/repo-worktree/SKILL.md @@ -1,134 +1,134 @@ ---- -name: repo-worktree -description: >- - Mandates and mechanizes task isolation in ptr727/ProjectTemplate fleet repos: every task, - including a continuation of a prior session's task, creates its own git worktree on its own - feature branch before its first file edit, based on the branch work starts on (develop on both - fleet workflow models unless the task is explicitly about main-only content, never whichever - branch a tool defaulted to). Also wraps the mechanics: - creating a worktree with git worktree add, the fleet layout convention, listing what is in - flight, and removing a worktree and its branch after merge. Use this whenever about to create - or edit files in a fleet repo, whenever starting or resuming a task, whenever the task's - branch is already checked out in a shared checkout, and whenever creating, listing, or - removing a worktree. Triggers even when the session was launched in the primary checkout or - the change looks like a one-line fix, because the primary checkout is the maintainer's own - surface and the incident this guards against was two sessions sharing one checkout, each - session's blanket add committing the other's uncommitted files. ---- - -# Repo Worktree - -## Why This Exists - -Two agent sessions once ran concurrently in the same primary checkout, on the same feature -branch, neither knowing the other was in the tree. One session's commits swept in the other -session's uncommitted files, so two commits landed carrying work their subjects never mention, -committed by a task that never saw it. No rule fired at the moment it was violated, which is the -first file edit: the commit-time and review-time skills all run after a sweep has already -happened. This skill is that missing task-start surface. `GOVERNANCE.md` "Repository Boundaries -and Write Safety" keeps the isolation law and wins on any disagreement, and the mechanics below -are this skill's own content. - -## The Mandate - -- **Every task isolates into its own worktree before its first file edit.** All new work begins - by creating a unique worktree (or clone) on its own feature branch. The primary checkout is - the maintainer's own surface, so a session launched there isolates before writing rather than - after noticing contention. -- **A continuation re-isolates.** A session resuming prior work finds its branch already checked - out somewhere and naturally resumes there, and that instinct is the hazard: a branch sitting - checked out in a shared tree is exactly how two sessions end up in one checkout. Create a - fresh worktree for the continuation and check the branch out there. -- **The moment is the first file edit, not the commit.** By commit time another task's - uncommitted work can already be swept into the staging area, so isolating late protects - nothing. Reading anywhere is fine, and the worktree exists before the first write. -- **Someone else's tree stays theirs.** A branch that changes when nothing you did changed it, - or an edit of yours reverted with no conflict, means another task is live in that tree, and - the response is to stop rather than to re-apply the edit, per `GOVERNANCE.md` "Repository - Boundaries and Write Safety". - -## The Base Branch - -Base the worktree on the branch work starts on for the repository's model, not on whichever -branch a tool defaulted to. GitHub's own "default branch" setting reads `main`, but on both -fleet workflow models work starts on `develop`, so a worktree defaulted to "the default branch" -lands on `main` and silently misses everything merged to `develop` but not yet promoted. Branch -from `develop` unless the task is explicitly about `main`-only content, per `GOVERNANCE.md` -"Branching Model". Fetch immediately before creating and base on the remote ref, because a clone -is whatever it last fetched rather than the branch it names. - -## Creating a Worktree - -The fleet layout convention keeps every base clone and every in-flight task visible in one -place: - -```text -~/repos/<Repo> base clone, on its default/working branch -~/repos/worktrees/<Repo>-<task-slug> one worktree per in-flight task, own branch -~/repos/upstream/<owner>-<repo> clone of a repo under another owner, not a fork -``` - -The top level carries no owner segment because everything in it is the fleet owner's own, an -original repo and a fork alike. A fork is named `<upstream-owner>-<upstream-repo>` at fork time, -so a fork of `acme/core` is `acme-core`, and its name identifies the upstream project and stays -unique in the flat namespace without an owner segment of its own. A repository adopted as the -owner's own work rather than kept as a fork is detached from its parent and keeps a plain name, -`widget` rather than `initech-widget`, since it no longer tracks anything upstream. - -A clone of a repository under another owner is neither of those, and flattening one collides -rather than merely reading oddly: `acme/core` joined the way a fork is joined **is** the fork's -name, `acme-core`, while reduced to a bare `core` it names no project and collides with the next -`core` cloned from any other owner. Those clones live one level down under `upstream/`, named by -that same join, so `upstream/acme-core` sits beside the fork it would otherwise land on. The -segment states the relationship rather than the owner, so a reference checkout is told from a -working repo without a `git remote` call, and the names under it never compete with the flat -namespace above. The join is ambiguous in the abstract, since a hyphen in either half means -`acme-labs/core` and `acme/labs-core` produce one name, and it is kept anyway because it is the -fork convention's own join: the ambiguity is inherited from the flat namespace above rather than -introduced here, and it surfaces at clone time as a directory that already exists, where the -second clone takes a hand-picked name. A worktree off one of them keeps the flat worktrees path -under the same name, `~/repos/worktrees/<owner>-<repo>-<task-slug>`. Contributing a change from -such a clone is never a push out of it: fork the upstream first, per the -`upstream-contribution-workflow` skill, and that fork's own clone then belongs in the flat -namespace above, under the name this one already has. - -```sh -git -C ~/repos/<Repo> fetch origin develop -git -C ~/repos/<Repo> worktree add ~/repos/worktrees/<Repo>-<task-slug> -b <task-branch> origin/develop -``` - -A continuation attaches the task's existing branch rather than forking a fresh one: - -```sh -git -C ~/repos/<Repo> fetch origin <task-branch> -git -C ~/repos/<Repo> worktree add ~/repos/worktrees/<Repo>-<task-slug> <task-branch> -``` - -When the base clone holds only the remote-tracking ref, the same command creates the local -branch tracking `origin/<task-branch>` 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. - -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 -has, and note that the base clone may live elsewhere than `~/repos/<Repo>`. - -Claude Code's own `EnterWorktree` tool acts only on an explicit instruction from the user or the -project instructions, which is why the carried rules state this mandate in so many words. Given -a `name`, it creates the worktree under `.claude/worktrees/` inside the repo and bases it on the -GitHub default branch, which is the wrong path and the wrong base here. Create the worktree with -`git worktree add` as above, then attach with `EnterWorktree` `path:`, not `name:`. - -## Listing and Cleanup - -- `git worktree list`, run in any checkout of a repo, names that repo's base clone and every - worktree with its branch. On the convention layout, one `ls ~/repos/worktrees/` reads what is - in flight across the whole fleet. -- After the task's pull request merges, remove the worktree and its branch from the base clone: - `git worktree remove ~/repos/worktrees/<Repo>-<task-slug>`, then `git branch -d <task-branch>`. -- A worktree that refuses removal is dirty, and force is not the fix: look at what is - uncommitted in it first, since discarding uncommitted work runs only on explicit instruction, - per the `git-commit-conventions` skill. +--- +name: repo-worktree +description: >- + Mandates and mechanizes task isolation in ptr727/ProjectTemplate fleet repos: every task, + including a continuation of a prior session's task, creates its own git worktree on its own + feature branch before its first file edit, based on the branch work starts on (develop on both + fleet workflow models unless the task is explicitly about main-only content, never whichever + branch a tool defaulted to). Also wraps the mechanics: + creating a worktree with git worktree add, the fleet layout convention, listing what is in + flight, and removing a worktree and its branch after merge. Use this whenever about to create + or edit files in a fleet repo, whenever starting or resuming a task, whenever the task's + branch is already checked out in a shared checkout, and whenever creating, listing, or + removing a worktree. Triggers even when the session was launched in the primary checkout or + the change looks like a one-line fix, because the primary checkout is the maintainer's own + surface and the incident this guards against was two sessions sharing one checkout, each + session's blanket add committing the other's uncommitted files. +--- + +# Repo Worktree + +## Why This Exists + +Two agent sessions once ran concurrently in the same primary checkout, on the same feature +branch, neither knowing the other was in the tree. One session's commits swept in the other +session's uncommitted files, so two commits landed carrying work their subjects never mention, +committed by a task that never saw it. No rule fired at the moment it was violated, which is the +first file edit: the commit-time and review-time skills all run after a sweep has already +happened. This skill is that missing task-start surface. `GOVERNANCE.md` "Repository Boundaries +and Write Safety" keeps the isolation law and wins on any disagreement, and the mechanics below +are this skill's own content. + +## The Mandate + +- **Every task isolates into its own worktree before its first file edit.** All new work begins + by creating a unique worktree (or clone) on its own feature branch. The primary checkout is + the maintainer's own surface, so a session launched there isolates before writing rather than + after noticing contention. +- **A continuation re-isolates.** A session resuming prior work finds its branch already checked + out somewhere and naturally resumes there, and that instinct is the hazard: a branch sitting + checked out in a shared tree is exactly how two sessions end up in one checkout. Create a + fresh worktree for the continuation and check the branch out there. +- **The moment is the first file edit, not the commit.** By commit time another task's + uncommitted work can already be swept into the staging area, so isolating late protects + nothing. Reading anywhere is fine, and the worktree exists before the first write. +- **Someone else's tree stays theirs.** A branch that changes when nothing you did changed it, + or an edit of yours reverted with no conflict, means another task is live in that tree, and + the response is to stop rather than to re-apply the edit, per `GOVERNANCE.md` "Repository + Boundaries and Write Safety". + +## The Base Branch + +Base the worktree on the branch work starts on for the repository's model, not on whichever +branch a tool defaulted to. GitHub's own "default branch" setting reads `main`, but on both +fleet workflow models work starts on `develop`, so a worktree defaulted to "the default branch" +lands on `main` and silently misses everything merged to `develop` but not yet promoted. Branch +from `develop` unless the task is explicitly about `main`-only content, per `GOVERNANCE.md` +"Branching Model". Fetch immediately before creating and base on the remote ref, because a clone +is whatever it last fetched rather than the branch it names. + +## Creating a Worktree + +The fleet layout convention keeps every base clone and every in-flight task visible in one +place: + +```text +~/repos/<Repo> base clone, on its default/working branch +~/repos/worktrees/<Repo>-<task-slug> one worktree per in-flight task, own branch +~/repos/upstream/<owner>-<repo> clone of a repo under another owner, not a fork +``` + +The top level carries no owner segment because everything in it is the fleet owner's own, an +original repo and a fork alike. A fork is named `<upstream-owner>-<upstream-repo>` at fork time, +so a fork of `acme/core` is `acme-core`, and its name identifies the upstream project and stays +unique in the flat namespace without an owner segment of its own. A repository adopted as the +owner's own work rather than kept as a fork is detached from its parent and keeps a plain name, +`widget` rather than `initech-widget`, since it no longer tracks anything upstream. + +A clone of a repository under another owner is neither of those, and flattening one collides +rather than merely reading oddly: `acme/core` joined the way a fork is joined **is** the fork's +name, `acme-core`, while reduced to a bare `core` it names no project and collides with the next +`core` cloned from any other owner. Those clones live one level down under `upstream/`, named by +that same join, so `upstream/acme-core` sits beside the fork it would otherwise land on. The +segment states the relationship rather than the owner, so a reference checkout is told from a +working repo without a `git remote` call, and the names under it never compete with the flat +namespace above. The join is ambiguous in the abstract, since a hyphen in either half means +`acme-labs/core` and `acme/labs-core` produce one name, and it is kept anyway because it is the +fork convention's own join: the ambiguity is inherited from the flat namespace above rather than +introduced here, and it surfaces at clone time as a directory that already exists, where the +second clone takes a hand-picked name. A worktree off one of them keeps the flat worktrees path +under the same name, `~/repos/worktrees/<owner>-<repo>-<task-slug>`. Contributing a change from +such a clone is never a push out of it: fork the upstream first, per the +`upstream-contribution-workflow` skill, and that fork's own clone then belongs in the flat +namespace above, under the name this one already has. + +```sh +git -C ~/repos/<Repo> fetch origin develop +git -C ~/repos/<Repo> worktree add ~/repos/worktrees/<Repo>-<task-slug> -b <task-branch> origin/develop +``` + +A continuation attaches the task's existing branch rather than forking a fresh one: + +```sh +git -C ~/repos/<Repo> fetch origin <task-branch> +git -C ~/repos/<Repo> worktree add ~/repos/worktrees/<Repo>-<task-slug> <task-branch> +``` + +When the base clone holds only the remote-tracking ref, the same command creates the local +branch tracking `origin/<task-branch>` 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. + +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 +has, and note that the base clone may live elsewhere than `~/repos/<Repo>`. + +Claude Code's own `EnterWorktree` tool acts only on an explicit instruction from the user or the +project instructions, which is why the carried rules state this mandate in so many words. Given +a `name`, it creates the worktree under `.claude/worktrees/` inside the repo and bases it on the +GitHub default branch, which is the wrong path and the wrong base here. Create the worktree with +`git worktree add` as above, then attach with `EnterWorktree` `path:`, not `name:`. + +## Listing and Cleanup + +- `git worktree list`, run in any checkout of a repo, names that repo's base clone and every + worktree with its branch. On the convention layout, one `ls ~/repos/worktrees/` reads what is + in flight across the whole fleet. +- After the task's pull request merges, remove the worktree and its branch from the base clone: + `git worktree remove ~/repos/worktrees/<Repo>-<task-slug>`, then `git branch -d <task-branch>`. +- A worktree that refuses removal is dirty, and force is not the fix: look at what is + uncommitted in it first, since discarding uncommitted work runs only on explicit instruction, + per the `git-commit-conventions` skill. diff --git a/.claude-plugin/fleet-skills/skills/resync-a-repo/SKILL.md b/.claude-plugin/fleet-skills/skills/resync-a-repo/SKILL.md index 011d68d4..308dcbc9 100644 --- a/.claude-plugin/fleet-skills/skills/resync-a-repo/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/resync-a-repo/SKILL.md @@ -1,80 +1,80 @@ ---- -name: resync-a-repo -description: >- - Drives RESYNC.md's procedure for bringing a ptr727/ProjectTemplate fleet repo that is already - stood up back into line with the current hub, run from a hub checkout against a named target - repo. Use this whenever asked to resync, sync, converge, or bring a specific repo up to date - 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. ---- - -# Resync a Repo - -## Why this exists - -RESYNC.md's own apply order already sequences the remedies so the rules land before the files -they govern and a deletion lands before the re-vendor that would otherwise refresh it. The -AGENTS.md-overwrite incident happened inside that same procedure, on the step that looked most -routine. This skill exists so the mandatory check survives contact with a real, time-pressured -resync instead of depending on an agent remembering to run it unprompted. It is a driver over -RESYNC.md, not a replacement for it. Read RESYNC.md itself for the deletion sweep, the -letters-versus-drift routing, the settings and ruleset step, and everything else that does not -change from one resync to the next. - -## Confirm the procedure before starting - -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 <RepoName>`, 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 - -Fetch a hub checkout of your own immediately before reading it, per RESYNC.md section 1, since a -stale clone answers confidently instead of failing, and verify the host with -`python3 scripts/host_gate.py --repo <path-to-target-checkout>`. 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. - -## Apply, in this order - -1. **The instruction set first.** `AGENTS.md` and `GOVERNANCE.md` verbatim sections, then - `CODESTYLE.md` and `WORKFLOW.md`, including the `AGENTS.md` skill-dependency pointer paragraph - (naming `scripts/skills_install.py` and where the fleet's Skills live) as one more verbatim - unit carried in this same step, not a separate pass. **Before any verbatim re-vendor in this - step, run the `carried-instruction-file-guard` skill's distinctive-phrase probe against the - target file, every time, without exception.** This is not advisory language to weigh against - how routine the diff looks, a diff that looks routine is exactly the shape the - AGENTS.md-overwrite incident took. Do not proceed to the re-vendor until the probe has run and - any local addition it finds has a destination, per that skill's own procedure. -2. **Deletions second, before any re-vendor.** Only a `retire` disposition in - `spec/divergences.json` authorizes removing a file, and the removal is swept tree-wide, per - RESYNC.md section 4, before the deletion counts as done. -3. **Verbatim re-vendors** for everything the probe in step 1 cleared. A finding classified - modified rather than stale gets its diff read before being overwritten, since it may be an - improvement the hub should adopt instead of a mistake to erase. -4. **Interface workflows.** Honor the named contract, required jobs, the ruleset-bound check name, - the artifact-name handoff, rather than copying bytes. -5. **Settings, rulesets, and secrets.** Run - `repo-config/configure.sh check <owner>/<repo> release|operational` against the repo by name, - then `apply` for what it reports, never from a carried copy. -6. **Intent files last, and by hand,** since nothing mechanical judges these. - -Reconcile the registry entry (`status`, `types`, `releaseTrigger`, `workflowModel`, -`driftNotes`) in the same pass, and delete a `driftNote` describing work this pass just finished -rather than leaving it standing. - -## Ship it - -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, done means measured, not applied. +--- +name: resync-a-repo +description: >- + Drives RESYNC.md's procedure for bringing a ptr727/ProjectTemplate fleet repo that is already + stood up back into line with the current hub, run from a hub checkout against a named target + repo. Use this whenever asked to resync, sync, converge, or bring a specific repo up to date + 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. +--- + +# Resync a Repo + +## Why this exists + +RESYNC.md's own apply order already sequences the remedies so the rules land before the files +they govern and a deletion lands before the re-vendor that would otherwise refresh it. The +AGENTS.md-overwrite incident happened inside that same procedure, on the step that looked most +routine. This skill exists so the mandatory check survives contact with a real, time-pressured +resync instead of depending on an agent remembering to run it unprompted. It is a driver over +RESYNC.md, not a replacement for it. Read RESYNC.md itself for the deletion sweep, the +letters-versus-drift routing, the settings and ruleset step, and everything else that does not +change from one resync to the next. + +## Confirm the procedure before starting + +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 <RepoName>`, 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 + +Fetch a hub checkout of your own immediately before reading it, per RESYNC.md section 1, since a +stale clone answers confidently instead of failing, and verify the host with +`python3 scripts/host_gate.py --repo <path-to-target-checkout>`. 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. + +## Apply, in this order + +1. **The instruction set first.** `AGENTS.md` and `GOVERNANCE.md` verbatim sections, then + `CODESTYLE.md` and `WORKFLOW.md`, including the `AGENTS.md` skill-dependency pointer paragraph + (naming `scripts/skills_install.py` and where the fleet's Skills live) as one more verbatim + unit carried in this same step, not a separate pass. **Before any verbatim re-vendor in this + step, run the `carried-instruction-file-guard` skill's distinctive-phrase probe against the + target file, every time, without exception.** This is not advisory language to weigh against + how routine the diff looks, a diff that looks routine is exactly the shape the + AGENTS.md-overwrite incident took. Do not proceed to the re-vendor until the probe has run and + any local addition it finds has a destination, per that skill's own procedure. +2. **Deletions second, before any re-vendor.** Only a `retire` disposition in + `spec/divergences.json` authorizes removing a file, and the removal is swept tree-wide, per + RESYNC.md section 4, before the deletion counts as done. +3. **Verbatim re-vendors** for everything the probe in step 1 cleared. A finding classified + modified rather than stale gets its diff read before being overwritten, since it may be an + improvement the hub should adopt instead of a mistake to erase. +4. **Interface workflows.** Honor the named contract, required jobs, the ruleset-bound check name, + the artifact-name handoff, rather than copying bytes. +5. **Settings, rulesets, and secrets.** Run + `repo-config/configure.sh check <owner>/<repo> release|operational` against the repo by name, + then `apply` for what it reports, never from a carried copy. +6. **Intent files last, and by hand,** since nothing mechanical judges these. + +Reconcile the registry entry (`status`, `types`, `releaseTrigger`, `workflowModel`, +`driftNotes`) in the same pass, and delete a `driftNote` describing work this pass just finished +rather than leaving it standing. + +## Ship it + +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, done means measured, not applied. diff --git a/.claude-plugin/fleet-skills/skills/shell-codestyle/SKILL.md b/.claude-plugin/fleet-skills/skills/shell-codestyle/SKILL.md index 5064582d..50b1f604 100644 --- a/.claude-plugin/fleet-skills/skills/shell-codestyle/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/shell-codestyle/SKILL.md @@ -1,55 +1,55 @@ ---- -name: shell-codestyle -description: >- - Governs Bash/shell script style for ptr727/ProjectTemplate fleet repos: when a bootstrap or - host-tool script may be shell instead of Python, the mandatory set -Eeuo pipefail header, the - pipefail-versus-early-reader pitfall, self-locating scripts, shellcheck cleanliness, and the - why-not-what comment rule. Use this whenever writing, reviewing, or editing a .sh file, whenever - deciding whether a new script should be Bash or Python, or whenever a pipeline built from - `curl`/`grep`/`jq`-style commands looks like it silently swallowed a failure. Triggers even when - the task looks like a one-line tweak to an existing script, because a missing `-e`/`pipefail`, - or a reader piped straight from a producer that closes the pipe early, are each invisible until - the exact failure mode they guard against actually happens. Fleet-wide: a shell script can - appear in any repo (a bootstrap that installs the interpreter, a host tool that must run before - a toolchain exists), not only a repo whose primary language is shell. ---- - -# Shell Codestyle - -## Why this exists - -This is the shell-specific half of the fleet's code style guide, kept in one place instead of -re-derived per repo or per session. Shell is the fleet's exception language, reached for only -where Python cannot run yet, so its rules exist to keep that narrow surface safe rather than to -cover general scripting style. - -## When shell, not Python - -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. - -## Rules - -- **`set -Eeuo pipefail`, before the first command the script runs.** A header comment sits above - it, as `repo-config/configure.sh` and the `host-setup/` scripts do, since what matters is that - nothing executes unguarded rather than which line number it lands on. Without `-e` a failed - command in the middle of a sequence lets the rest run against a state nobody checked, and - without `pipefail` a pipeline reports the exit of its last stage, so a fetch that failed reads - as an answer when a parser downstream succeeds on an empty input. `-E` carries an `ERR` trap - into functions and command substitutions, so a script that later adds one is not surprised by - where it does not fire. -- **A reader that stops early needs its producer read first.** Under `pipefail`, a producer - writing to a closed pipe exits non-zero, so `curl ... | grep -q` reports a successful fetch as - a failure whenever the match is found early enough. Capture the output, then search it. -- **Self-locating, never dependent on the caller's directory.** A script resolves its own - directory from `BASH_SOURCE` and references its payloads through it, since the working - directory at invocation is not a property of the script. -- **`shellcheck` clean, and a deliberate exception carries its reason inline.** A - `# shellcheck disable=SCxxxx` names why the rule does not apply here, so the next reader can - tell a considered exception from an unread warning. `repo-config/configure.sh` is the worked - example, carrying five `SC2016` disables where a single-quoted `jq` program must stay - unexpanded, each with its reason on the same line. -- **Comments say why, never what.** The code states what it does. A comment restating it goes - stale silently, where a comment carrying a reason fails visibly when the reason stops being - true. +--- +name: shell-codestyle +description: >- + Governs Bash/shell script style for ptr727/ProjectTemplate fleet repos: when a bootstrap or + host-tool script may be shell instead of Python, the mandatory set -Eeuo pipefail header, the + pipefail-versus-early-reader pitfall, self-locating scripts, shellcheck cleanliness, and the + why-not-what comment rule. Use this whenever writing, reviewing, or editing a .sh file, whenever + deciding whether a new script should be Bash or Python, or whenever a pipeline built from + `curl`/`grep`/`jq`-style commands looks like it silently swallowed a failure. Triggers even when + the task looks like a one-line tweak to an existing script, because a missing `-e`/`pipefail`, + or a reader piped straight from a producer that closes the pipe early, are each invisible until + the exact failure mode they guard against actually happens. Fleet-wide: a shell script can + appear in any repo (a bootstrap that installs the interpreter, a host tool that must run before + a toolchain exists), not only a repo whose primary language is shell. +--- + +# Shell Codestyle + +## Why this exists + +This is the shell-specific half of the fleet's code style guide, kept in one place instead of +re-derived per repo or per session. Shell is the fleet's exception language, reached for only +where Python cannot run yet, so its rules exist to keep that narrow surface safe rather than to +cover general scripting style. + +## When shell, not Python + +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. + +## Rules + +- **`set -Eeuo pipefail`, before the first command the script runs.** A header comment sits above + it, as `repo-config/configure.sh` and the `host-setup/` scripts do, since what matters is that + nothing executes unguarded rather than which line number it lands on. Without `-e` a failed + command in the middle of a sequence lets the rest run against a state nobody checked, and + without `pipefail` a pipeline reports the exit of its last stage, so a fetch that failed reads + as an answer when a parser downstream succeeds on an empty input. `-E` carries an `ERR` trap + into functions and command substitutions, so a script that later adds one is not surprised by + where it does not fire. +- **A reader that stops early needs its producer read first.** Under `pipefail`, a producer + writing to a closed pipe exits non-zero, so `curl ... | grep -q` reports a successful fetch as + a failure whenever the match is found early enough. Capture the output, then search it. +- **Self-locating, never dependent on the caller's directory.** A script resolves its own + directory from `BASH_SOURCE` and references its payloads through it, since the working + directory at invocation is not a property of the script. +- **`shellcheck` clean, and a deliberate exception carries its reason inline.** A + `# shellcheck disable=SCxxxx` names why the rule does not apply here, so the next reader can + tell a considered exception from an unread warning. `repo-config/configure.sh` is the worked + example, carrying five `SC2016` disables where a single-quoted `jq` program must stay + unexpanded, each with its reason on the same line. +- **Comments say why, never what.** The code states what it does. A comment restating it goes + stale silently, where a comment carrying a reason fails visibly when the reason stops being + true. diff --git a/.claude-plugin/fleet-skills/skills/skill-lifecycle/SKILL.md b/.claude-plugin/fleet-skills/skills/skill-lifecycle/SKILL.md index 92972b0f..85a73c51 100644 --- a/.claude-plugin/fleet-skills/skills/skill-lifecycle/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/skill-lifecycle/SKILL.md @@ -1,49 +1,49 @@ ---- -name: skill-lifecycle -description: >- - Governs the lifecycle of the fleet's own skills in ptr727/ProjectTemplate: creating, changing, splitting, and retiring a skill under .agents/skills/, the source-versus-generated split with .claude-plugin/, the regenerate and --check semantics of scripts/build_dist.py, the install and stamp semantics of scripts/skills_install.py, the doc-packaging pattern that keeps a law doc and its skill in agreement, and the trigger-description conventions that make a skill fire. Use this whenever about to create, edit, move, or delete anything under .agents/skills/ or .claude-plugin/, whenever packaging a doc or a doc section as a skill, and whenever deciding whether a topic deserves a skill at all. Triggers even when the edit looks trivial, such as fixing a typo in one SKILL.md, because the generated plugin desyncs the moment the source changes without a build_dist.py run, and CI fails the pull request on exactly that. Hub-context only, since .agents/skills/ exists only in the hub. ---- - -# Skill Lifecycle - -## Why This Exists - -The agent most likely to get a skill wrong is the one editing a skill, and before this skill existed nothing watched that moment: the regenerate and install semantics lived in `scripts/` docstrings and scattered prose, so the procedure was rediscovered per session. The two standing hazards are mechanical and silent. A hand-edit to the generated `.claude-plugin/` tree is overwritten by the next regenerate, and a source edit without a regenerate ships a plugin that no longer matches its source, which the CI `--check` gate fails rather than anyone noticing in review. - -## The Pipeline - -- **`.agents/skills/<name>/SKILL.md` is the only hand-authored source**, with optional `references/` and `scripts/` directories beside it. Codex and opencode read this tree directly, project-local, and also read the global `~/.agents/skills/` copy the installer materializes. -- **Claude Code never scans that path.** `scripts/build_dist.py` generates a Claude-plugin-compatible copy at `.claude-plugin/fleet-skills/`, published through `.claude-plugin/marketplace.json`. The generated tree is never hand-edited, and `build_dist.py --check` exits non-zero when it was built from different source bytes than `.agents/skills/` currently holds. -- **The skill set is implicit.** Every `.agents/skills/<name>/` directory carrying a `SKILL.md` is a skill, and the generated `plugin.json` derives its list from those directories, so adding or retiring a skill edits no manifest by hand. `marketplace.json` names the plugin, not the skills, and is untouched by ordinary lifecycle work. -- **`scripts/skills_install.py`, run from a hub checkout, installs both forms per machine**: an overlay copy into `~/.agents/skills/` for Codex and opencode, marked per skill so a retired skill is removed on the next run and a foreign skill is never touched, and a user-scope plugin install for Claude Code via the `claude` CLI. Each run stamps the hub commit into `~/.agents/skills-install-stamp.json`, and `--report` reads that stamp against the checkout and exits non-zero when the machine is behind. The install is global per user, and per-repo pinning is a settled non-goal (`docs/fleet-map.md` "Skills Install Model"). - -## Deciding a Topic Deserves a Skill - -A skill surfaces at a trigger moment. A rule that binds every action all the time, or a short reference section a task reads once, gains nothing from being one: the always-on layer is the carried instruction set (`AGENTS.md` and the sections it maps), and packaging it as a skill duplicates it and spends the tokens the delegation rules exist to save. The `AGENTS.md` "Where the Rules Live" map records the disposition either way, a skill annotation on the row or the deliberate absence of one, so a topic with no skill reads as a decision rather than an oversight. - -## 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. -3. **Author the body per the `comment-and-doc-style` skill**: CRLF (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 both trees together**: `python3 scripts/build_dist.py`, then commit the source and the regenerated `.claude-plugin/` in one commit. CI runs `--check` on every pull request and fails a desynced pair. `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. - -## Changing or Retiring a Skill - -- **Edit only the source tree.** Any content change under `.claude-plugin/` that did not come from a `build_dist.py` run is a defect, whatever it fixes. -- **Retiring is deleting the source directory and regenerating.** The derived `plugin.json` list shrinks with it, and the installer's per-skill markers remove the retired skill from `~/.agents/skills/` on each machine's next run. -- **A deletion sweeps the prose that references the skill**, in the same change rather than as follow-up: the `AGENTS.md` map row or paragraph naming it, any law-doc packaging pointer to it, and any sibling skill that disambiguates against it. A law-doc section that had moved its full rules into the skill takes them back, or is retired with it, so no rule is silently lost with the skill that carried it. -- **Renaming is a retire plus a create** as far as the installer's markers and the plugin list are concerned, so sweep references the same way. - -## The Doc-Packaging Pattern - -Packaging keeps one topic in one authoritative place while the skill makes it surface automatically. It has two shapes, and each pairing states which it uses: - -- **Moved content.** The law-doc section keeps a summary and the skill holds the full rules (`git-commit-conventions`, `comment-and-doc-style`, `pr-review-conduct`). The section ends with the standard pointer sentence: packaged as the named skill at `.agents/skills/<name>/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, read the skill for the full rules. -- **Kept authority.** The source doc keeps the full rules and the skill is the summary that routes to them (`audit-a-repo` over `AUDIT.md`, `workflow-ci-contract` over `WORKFLOW.md`, `agent-conduct` over its GOVERNANCE sections). The skill states per topic which doc section owns it. - -In both shapes the doc wins on any disagreement, and the skill is what needs fixing. A rule stated fully in both places is the drift this pattern exists to prevent, so an edit to a packaged rule lands in its owning place and the other side's summary is checked against it in the same change. +--- +name: skill-lifecycle +description: >- + Governs the lifecycle of the fleet's own skills in ptr727/ProjectTemplate: creating, changing, splitting, and retiring a skill under .agents/skills/, the source-versus-generated split with .claude-plugin/, the regenerate and --check semantics of scripts/build_dist.py, the install and stamp semantics of scripts/skills_install.py, the doc-packaging pattern that keeps a law doc and its skill in agreement, and the trigger-description conventions that make a skill fire. Use this whenever about to create, edit, move, or delete anything under .agents/skills/ or .claude-plugin/, whenever packaging a doc or a doc section as a skill, and whenever deciding whether a topic deserves a skill at all. Triggers even when the edit looks trivial, such as fixing a typo in one SKILL.md, because the generated plugin desyncs the moment the source changes without a build_dist.py run, and CI fails the pull request on exactly that. Hub-context only, since .agents/skills/ exists only in the hub. +--- + +# Skill Lifecycle + +## Why This Exists + +The agent most likely to get a skill wrong is the one editing a skill, and before this skill existed nothing watched that moment: the regenerate and install semantics lived in `scripts/` docstrings and scattered prose, so the procedure was rediscovered per session. The two standing hazards are mechanical and silent. A hand-edit to the generated `.claude-plugin/` tree is overwritten by the next regenerate, and a source edit without a regenerate ships a plugin that no longer matches its source, which the CI `--check` gate fails rather than anyone noticing in review. + +## The Pipeline + +- **`.agents/skills/<name>/SKILL.md` is the only hand-authored source**, with optional `references/` and `scripts/` directories beside it. Codex and opencode read this tree directly, project-local, and also read the global `~/.agents/skills/` copy the installer materializes. +- **Claude Code never scans that path.** `scripts/build_dist.py` generates a Claude-plugin-compatible copy at `.claude-plugin/fleet-skills/`, published through `.claude-plugin/marketplace.json`. The generated tree is never hand-edited, and `build_dist.py --check` exits non-zero when it was built from different source bytes than `.agents/skills/` currently holds. +- **The skill set is implicit.** Every `.agents/skills/<name>/` directory carrying a `SKILL.md` is a skill, and the generated `plugin.json` derives its list from those directories, so adding or retiring a skill edits no manifest by hand. `marketplace.json` names the plugin, not the skills, and is untouched by ordinary lifecycle work. +- **`scripts/skills_install.py`, run from a hub checkout, installs both forms per machine**: an overlay copy into `~/.agents/skills/` for Codex and opencode, marked per skill so a retired skill is removed on the next run and a foreign skill is never touched, and a user-scope plugin install for Claude Code via the `claude` CLI. Each run stamps the hub commit into `~/.agents/skills-install-stamp.json`, and `--report` reads that stamp against the checkout and exits non-zero when the machine is behind. The install is global per user, and per-repo pinning is a settled non-goal (`docs/fleet-map.md` "Skills Install Model"). + +## Deciding a Topic Deserves a Skill + +A skill surfaces at a trigger moment. A rule that binds every action all the time, or a short reference section a task reads once, gains nothing from being one: the always-on layer is the carried instruction set (`AGENTS.md` and the sections it maps), and packaging it as a skill duplicates it and spends the tokens the delegation rules exist to save. The `AGENTS.md` "Where the Rules Live" map records the disposition either way, a skill annotation on the row or the deliberate absence of one, so a topic with no skill reads as a decision rather than an oversight. + +## 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. +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 both trees together**: `python3 scripts/build_dist.py`, then commit the source and the regenerated `.claude-plugin/` in one commit. CI runs `--check` on every pull request and fails a desynced pair. `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. + +## Changing or Retiring a Skill + +- **Edit only the source tree.** Any content change under `.claude-plugin/` that did not come from a `build_dist.py` run is a defect, whatever it fixes. +- **Retiring is deleting the source directory and regenerating.** The derived `plugin.json` list shrinks with it, and the installer's per-skill markers remove the retired skill from `~/.agents/skills/` on each machine's next run. +- **A deletion sweeps the prose that references the skill**, in the same change rather than as follow-up: the `AGENTS.md` map row or paragraph naming it, any law-doc packaging pointer to it, and any sibling skill that disambiguates against it. A law-doc section that had moved its full rules into the skill takes them back, or is retired with it, so no rule is silently lost with the skill that carried it. +- **Renaming is a retire plus a create** as far as the installer's markers and the plugin list are concerned, so sweep references the same way. + +## The Doc-Packaging Pattern + +Packaging keeps one topic in one authoritative place while the skill makes it surface automatically. It has two shapes, and each pairing states which it uses: + +- **Moved content.** The law-doc section keeps a summary and the skill holds the full rules (`git-commit-conventions`, `comment-and-doc-style`, `pr-review-conduct`). The section ends with the standard pointer sentence: packaged as the named skill at `.agents/skills/<name>/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, read the skill for the full rules. +- **Kept authority.** The source doc keeps the full rules and the skill is the summary that routes to them (`audit-a-repo` over `AUDIT.md`, `workflow-ci-contract` over `WORKFLOW.md`, `agent-conduct` over its GOVERNANCE sections). The skill states per topic which doc section owns it. + +In both shapes the doc wins on any disagreement, and the skill is what needs fixing. A rule stated fully in both places is the drift this pattern exists to prevent, so an edit to a packaged rule lands in its owning place and the other side's summary is checked against it in the same change. diff --git a/.claude-plugin/fleet-skills/skills/standup-a-repo/SKILL.md b/.claude-plugin/fleet-skills/skills/standup-a-repo/SKILL.md index 4a992707..56c66a1e 100644 --- a/.claude-plugin/fleet-skills/skills/standup-a-repo/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/standup-a-repo/SKILL.md @@ -1,99 +1,99 @@ ---- -name: standup-a-repo -description: >- - Drives STANDUP.md's procedure for taking a ptr727/ProjectTemplate fleet repo from nothing (or a - partial state) to operational against the fleet ground truth, run from a hub checkout for a - named target repo the maintainer is standing up. Use this whenever asked to stand up, create, - 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 - 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. ---- - -# Stand Up a Repo - -## Why this exists - -STANDUP.md's own section order exists because several of its steps close a window that cannot be -reopened cheaply: commit signing has to be correct before the first commit, the long-lived -branches have to exist before any standup commit lands on one, and the instruction set has to be -carried before anything else is authored against it. This skill exists so that order survives -contact with a real, time-pressured standup instead of depending on an agent remembering to run -each gate unprompted. It is a driver over STANDUP.md, not a replacement for it. Read STANDUP.md -itself for the full text of every step, the onboarding-a-new-repo-type procedure, and the -cold-start self-test. - -## Before starting - -Read STANDUP.md section 0A first. Nothing in this procedure creates the GitHub repository, its -App, or its secrets, each an outward-facing write that needs the maintainer's explicit permission -and inputs, so hand that checklist over before step 1 rather than discovering the gap partway -through. A repo with no remote is not partially stood up, it is not started, and only the -maintainer can supply what section 0A lists. - -## Apply, in order - -1. **Signing, before the first commit.** STANDUP.md section 0: verify, never set, the inherited - `--global` commit identity and signing configuration, and the host tool floors via - `python3 scripts/host_gate.py`. The window closes at the first commit, since a repo committed - under the wrong identity or unsigned cannot be cleanly repaired afterward. - -2. **Branches, before the first standup commit.** STANDUP.md section 0B: create `main` and - `develop` empty, off one signed empty root commit, then run every step below on a feature - branch off `develop`. Never commit standup work directly onto `develop`. `non_fast_forward` on - both branch payloads, or the missing blocking rule on an operational repo's `develop` ruleset, - makes that mistake either unrecoverable or silently unprotected. - -3. **Classify and catalog.** STANDUP.md section 1: resolve the repo's type(s) against `AUDIT.md` - section 2, then write or repair its `registry/repos.json` entry and confirm it with - `spec/validate.py`. - -4. **The instruction set, before authoring anything.** STANDUP.md section 1A: carry `AGENTS.md`, - `GOVERNANCE.md`, `CODESTYLE.md`, `WORKFLOW.md` and `AUDIT.md`, adapted rather than cloned for - the ones that describe a repo, plus `.markdownlint-cli2.jsonc` and `cspell.json`. Read - `CODESTYLE.md` and the `GOVERNANCE.md` documentation-style rules before writing any repo - content of your own, the same window-closes shape as signing in step 1. - -5. **Capture the source, if one exists.** STANDUP.md section 1B, only when the repo's content - replaces a live external system: capture it and verify the capture against the source before - anything is scaffolded from it, since the source is not under version control and cannot be - re-derived once it stops serving. - -6. **The baseline files.** STANDUP.md section 2: copy every `spec/files.json` entry whose - `appliesTo` matches the repo's selector set, adapted rather than cloned, and choose - `version.json`'s version floor deliberately rather than propagating the template's. Carry - `AGENTS.md`'s skill-dependency pointer paragraph, naming `scripts/skills_install.py` and where - the fleet's Skills live, as one more verbatim unit in this same step, not a separate pass, the - identical requirement `RESYNC.md` places on a repo already stood up. - -7. **The workflows.** STANDUP.md section 3: implement the Actions `WORKFLOW.md` requires for the - repo's type, reusing `catalog/snippets/workflows/` as the reference implementation rather than - inventing a shape. - -8. **Settings, rulesets, and secrets.** STANDUP.md section 4: confirm the remote and the GitHub - repository agree before running anything else here, then apply with - `repo-config/configure.sh apply owner/repo release|operational` and check with the same - command's `check` subcommand, never from a hand-built or carried copy. - -9. **Verify with the audit.** STANDUP.md section 5: run `AUDIT.md` end to end. The repo is stood - up only when it passes for its type, or its residual deltas are tracked in - `reports/<repo>/audit.md` plus an issue. - -## Onboarding a new repo type - -When a repo matches no existing type in `spec/project-types.json`, that is a type to onboard, not -a repo to force into the nearest existing one. STANDUP.md's "Onboarding a New Repo Type" section -covers the manifest additions (`spec/project-types.json`, `spec/files.json`, `spec/secrets.json`, -`spec/scope-model.md`, `spec/type-model.md`, and the `registry/repos.schema.json` target enum for -a new publish destination) and the cold-start self-test that proves the result usable by a -context-free agent, not just by the one that wrote it. - -## Ship it - -One pull request per standup, branched from `develop` per step 2 above, into `develop`, never a -direct push to a protected branch. 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. +--- +name: standup-a-repo +description: >- + Drives STANDUP.md's procedure for taking a ptr727/ProjectTemplate fleet repo from nothing (or a + partial state) to operational against the fleet ground truth, run from a hub checkout for a + named target repo the maintainer is standing up. Use this whenever asked to stand up, create, + 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 + 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. +--- + +# Stand Up a Repo + +## Why this exists + +STANDUP.md's own section order exists because several of its steps close a window that cannot be +reopened cheaply: commit signing has to be correct before the first commit, the long-lived +branches have to exist before any standup commit lands on one, and the instruction set has to be +carried before anything else is authored against it. This skill exists so that order survives +contact with a real, time-pressured standup instead of depending on an agent remembering to run +each gate unprompted. It is a driver over STANDUP.md, not a replacement for it. Read STANDUP.md +itself for the full text of every step, the onboarding-a-new-repo-type procedure, and the +cold-start self-test. + +## Before starting + +Read STANDUP.md section 0A first. Nothing in this procedure creates the GitHub repository, its +App, or its secrets, each an outward-facing write that needs the maintainer's explicit permission +and inputs, so hand that checklist over before step 1 rather than discovering the gap partway +through. A repo with no remote is not partially stood up, it is not started, and only the +maintainer can supply what section 0A lists. + +## Apply, in order + +1. **Signing, before the first commit.** STANDUP.md section 0: verify, never set, the inherited + `--global` commit identity and signing configuration, and the host tool floors via + `python3 scripts/host_gate.py`. The window closes at the first commit, since a repo committed + under the wrong identity or unsigned cannot be cleanly repaired afterward. + +2. **Branches, before the first standup commit.** STANDUP.md section 0B: create `main` and + `develop` empty, off one signed empty root commit, then run every step below on a feature + branch off `develop`. Never commit standup work directly onto `develop`. `non_fast_forward` on + both branch payloads, or the missing blocking rule on an operational repo's `develop` ruleset, + makes that mistake either unrecoverable or silently unprotected. + +3. **Classify and catalog.** STANDUP.md section 1: resolve the repo's type(s) against `AUDIT.md` + section 2, then write or repair its `registry/repos.json` entry and confirm it with + `spec/validate.py`. + +4. **The instruction set, before authoring anything.** STANDUP.md section 1A: carry `AGENTS.md`, + `GOVERNANCE.md`, `CODESTYLE.md`, `WORKFLOW.md` and `AUDIT.md`, adapted rather than cloned for + the ones that describe a repo, plus `.markdownlint-cli2.jsonc` and `cspell.json`. Read + `CODESTYLE.md` and the `GOVERNANCE.md` documentation-style rules before writing any repo + content of your own, the same window-closes shape as signing in step 1. + +5. **Capture the source, if one exists.** STANDUP.md section 1B, only when the repo's content + replaces a live external system: capture it and verify the capture against the source before + anything is scaffolded from it, since the source is not under version control and cannot be + re-derived once it stops serving. + +6. **The baseline files.** STANDUP.md section 2: copy every `spec/files.json` entry whose + `appliesTo` matches the repo's selector set, adapted rather than cloned, and choose + `version.json`'s version floor deliberately rather than propagating the template's. Carry + `AGENTS.md`'s skill-dependency pointer paragraph, naming `scripts/skills_install.py` and where + the fleet's Skills live, as one more verbatim unit in this same step, not a separate pass, the + identical requirement `RESYNC.md` places on a repo already stood up. + +7. **The workflows.** STANDUP.md section 3: implement the Actions `WORKFLOW.md` requires for the + repo's type, reusing `catalog/snippets/workflows/` as the reference implementation rather than + inventing a shape. + +8. **Settings, rulesets, and secrets.** STANDUP.md section 4: confirm the remote and the GitHub + repository agree before running anything else here, then apply with + `repo-config/configure.sh apply owner/repo release|operational` and check with the same + command's `check` subcommand, never from a hand-built or carried copy. + +9. **Verify with the audit.** STANDUP.md section 5: run `AUDIT.md` end to end. The repo is stood + up only when it passes for its type, or its residual deltas are tracked in + `reports/<repo>/audit.md` plus an issue. + +## Onboarding a new repo type + +When a repo matches no existing type in `spec/project-types.json`, that is a type to onboard, not +a repo to force into the nearest existing one. STANDUP.md's "Onboarding a New Repo Type" section +covers the manifest additions (`spec/project-types.json`, `spec/files.json`, `spec/secrets.json`, +`spec/scope-model.md`, `spec/type-model.md`, and the `registry/repos.schema.json` target enum for +a new publish destination) and the cold-start self-test that proves the result usable by a +context-free agent, not just by the one that wrote it. + +## Ship it + +One pull request per standup, branched from `develop` per step 2 above, into `develop`, never a +direct push to a protected branch. 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. diff --git a/.claude-plugin/fleet-skills/skills/upstream-contribution-workflow/SKILL.md b/.claude-plugin/fleet-skills/skills/upstream-contribution-workflow/SKILL.md index 6c8ff84d..3f88062a 100644 --- a/.claude-plugin/fleet-skills/skills/upstream-contribution-workflow/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/upstream-contribution-workflow/SKILL.md @@ -1,80 +1,80 @@ ---- -name: upstream-contribution-workflow -description: >- - Governs how the maintainer contributes to a third-party repository he does not control (for - example esphome/esphome), distinct from the fleet's own internal branching model: a dirty work - branch on his own fork for the actual work and review iteration, squashed once clean to a second - branch that carries only the intended minimal history, that clean branch opened as the PR - against the upstream repo, and reviewer feedback applied to the dirty branch first, then - re-squashed into the clean one. Use this whenever about to open a pull request against a - repository outside the ptr727 fleet, whenever forking a third-party project to contribute a fix - or feature, whenever an upstream reviewer requests changes on a PR opened this way, and whenever - deciding which issue or PR template to use for a third-party repository. Triggers regardless of - the target repo's own type or workflow model, since this skill is about the shape of a - contribution to someone else's repo, not the target repo's own internal conventions, which this - skill does not attempt to state and are never assumed to match the fleet's. ---- - -# Upstream Contribution Workflow - -## Why this exists - -The fleet's own branching model (`operational-vs-release-workflow`) 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 -cycles, and the history that lands there should read as a deliberate, minimal contribution, not as -the maintainer's own iteration log. This skill is that different shape, kept separate from the -fleet's internal model so the two are never conflated. - -## The two-branch shape - -1. **Fork the upstream repo**, if not already forked. -2. **Do the actual work on a dirty work branch**, on the maintainer's own fork. This branch is - allowed to be messy: false starts, fixup commits, back-and-forth in response to review, whatever - the real work looks like while it's happening. Open a PR from this branch into a branch on the - maintainer's **own fork** (not upstream), so all the iteration happens there, visible and - reviewable, without touching the upstream repo at all. -3. **Once the dirty branch is clean and the change is ready, squash it to a second branch** that - carries only the intended, minimal commit history, one commit (or a small, deliberate set) that - states what the change is, not how it was arrived at. -4. **Open the PR against the upstream repo from that second, clean branch.** This is the only - branch upstream ever sees. -5. **If upstream reviewers ask for changes, apply them to the dirty branch first**, iterate there - the same way as step 2, then re-squash the updated dirty branch into the clean branch that - actually reaches upstream. Updating the same upstream PR rather than opening a new one each - round rewrites the clean branch's history, and pushing a rewritten branch that is already - published requires `git push --force-with-lease` (prefer it over a bare `--force`, it refuses - the push if the remote moved since the last fetch). **`git-commit-conventions`'s never-force-push - rule governs this fleet's own repos, where a branch is shared with bots, other branches, and - required-check history a rewrite would orphan, and it stays absolute there, with no exception. - It has no jurisdiction here**: this clean presentation branch lives on the maintainer's own - fork, outside the fleet entirely, and carries nobody's work but this squash. Force-with-lease - is scoped just as tightly regardless: only this one branch, only on the maintainer's own fork, - never the dirty work branch, which is the append-only iteration log this whole workflow exists - to preserve. If force-with-lease is ever refused or unavailable, open a fresh PR from a newly - named clean branch rather than fighting the push. - -**The dirty branch is always the working copy. The clean branch is always the presentation copy.** -Never reverse this: never iterate directly on the branch that's open against upstream, and never -skip the squash step because the dirty branch "looks clean enough." - -## Use the upstream repo's own conventions, not the fleet's - -Always use the upstream repo's own issue and PR templates, its own contribution guidelines, and -its own commit-message and code-style conventions when they differ from this fleet's. The fleet's -`comment-and-doc-style`, `git-commit-conventions`, and `pr-review-conduct` skills describe how -*this fleet* does things, and none of them are the target repository's own rules. Read the target -repo's `CONTRIBUTING.md` (or equivalent) and follow it. Where the target repo states no -convention of its own, matching the surrounding code's existing style in that file is the better -default, not falling back to the fleet's own convention by habit. - -## What stays governed by the fleet's own rules - -Signing commits and using the correct git identity are host configuration, not project -convention, so `git-commit-conventions`'s signing and identity rules still apply on both the dirty -and clean branches. They are properties of the committer, not of the target repository. The -write-safety rules (never write to a repository outside explicit authorization, never fabricate a -GitHub id) also still apply in full. A fork the maintainer owns is within scope to push to, and -the upstream repository itself is written to only through the PR the maintainer explicitly asked -for. +--- +name: upstream-contribution-workflow +description: >- + Governs how the maintainer contributes to a third-party repository he does not control (for + example esphome/esphome), distinct from the fleet's own internal branching model: a dirty work + branch on his own fork for the actual work and review iteration, squashed once clean to a second + branch that carries only the intended minimal history, that clean branch opened as the PR + against the upstream repo, and reviewer feedback applied to the dirty branch first, then + re-squashed into the clean one. Use this whenever about to open a pull request against a + repository outside the ptr727 fleet, whenever forking a third-party project to contribute a fix + or feature, whenever an upstream reviewer requests changes on a PR opened this way, and whenever + deciding which issue or PR template to use for a third-party repository. Triggers regardless of + the target repo's own type or workflow model, since this skill is about the shape of a + contribution to someone else's repo, not the target repo's own internal conventions, which this + skill does not attempt to state and are never assumed to match the fleet's. +--- + +# Upstream Contribution Workflow + +## Why this exists + +The fleet's own branching model (`operational-vs-release-workflow`) 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 +cycles, and the history that lands there should read as a deliberate, minimal contribution, not as +the maintainer's own iteration log. This skill is that different shape, kept separate from the +fleet's internal model so the two are never conflated. + +## The two-branch shape + +1. **Fork the upstream repo**, if not already forked. +2. **Do the actual work on a dirty work branch**, on the maintainer's own fork. This branch is + allowed to be messy: false starts, fixup commits, back-and-forth in response to review, whatever + the real work looks like while it's happening. Open a PR from this branch into a branch on the + maintainer's **own fork** (not upstream), so all the iteration happens there, visible and + reviewable, without touching the upstream repo at all. +3. **Once the dirty branch is clean and the change is ready, squash it to a second branch** that + carries only the intended, minimal commit history, one commit (or a small, deliberate set) that + states what the change is, not how it was arrived at. +4. **Open the PR against the upstream repo from that second, clean branch.** This is the only + branch upstream ever sees. +5. **If upstream reviewers ask for changes, apply them to the dirty branch first**, iterate there + the same way as step 2, then re-squash the updated dirty branch into the clean branch that + actually reaches upstream. Updating the same upstream PR rather than opening a new one each + round rewrites the clean branch's history, and pushing a rewritten branch that is already + published requires `git push --force-with-lease` (prefer it over a bare `--force`, it refuses + the push if the remote moved since the last fetch). **`git-commit-conventions`'s never-force-push + rule governs this fleet's own repos, where a branch is shared with bots, other branches, and + required-check history a rewrite would orphan, and it stays absolute there, with no exception. + It has no jurisdiction here**: this clean presentation branch lives on the maintainer's own + fork, outside the fleet entirely, and carries nobody's work but this squash. Force-with-lease + is scoped just as tightly regardless: only this one branch, only on the maintainer's own fork, + never the dirty work branch, which is the append-only iteration log this whole workflow exists + to preserve. If force-with-lease is ever refused or unavailable, open a fresh PR from a newly + named clean branch rather than fighting the push. + +**The dirty branch is always the working copy. The clean branch is always the presentation copy.** +Never reverse this: never iterate directly on the branch that's open against upstream, and never +skip the squash step because the dirty branch "looks clean enough." + +## Use the upstream repo's own conventions, not the fleet's + +Always use the upstream repo's own issue and PR templates, its own contribution guidelines, and +its own commit-message and code-style conventions when they differ from this fleet's. The fleet's +`comment-and-doc-style`, `git-commit-conventions`, and `pr-review-conduct` skills describe how +*this fleet* does things, and none of them are the target repository's own rules. Read the target +repo's `CONTRIBUTING.md` (or equivalent) and follow it. Where the target repo states no +convention of its own, matching the surrounding code's existing style in that file is the better +default, not falling back to the fleet's own convention by habit. + +## What stays governed by the fleet's own rules + +Signing commits and using the correct git identity are host configuration, not project +convention, so `git-commit-conventions`'s signing and identity rules still apply on both the dirty +and clean branches. They are properties of the committer, not of the target repository. The +write-safety rules (never write to a repository outside explicit authorization, never fabricate a +GitHub id) also still apply in full. A fork the maintainer owns is within scope to push to, and +the upstream repository itself is written to only through the PR the maintainer explicitly asked +for. diff --git a/.claude-plugin/fleet-skills/skills/workflow-ci-contract/SKILL.md b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/SKILL.md index 8160ce98..685a4cac 100644 --- a/.claude-plugin/fleet-skills/skills/workflow-ci-contract/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/SKILL.md @@ -1,47 +1,47 @@ ---- -name: workflow-ci-contract -description: >- - Governs the WORKFLOW.md CI/CD behavioral contract for every ptr727/ProjectTemplate fleet repo: the D1-D9 guarantees stated as the failure mode each prevents, the seam contract for release assets, the artifact lifecycle, NBGV versioning and classification, validate-at-entry, and the 5A/5B/5C test methodology with its per-type walkthroughs. Use this whenever writing or editing anything under .github/workflows/, adding or dropping a release target, auditing a repo's workflows, or reasoning about why a publish did or did not fire. This is the YAML half of the pipeline, and the operational-vs-release-workflow skill keeps the git half (branching, promotion, publish policy), so branch choice questions go there. Triggers even when the edit looks mechanical, such as bumping an action, renaming a job, or adding one upload step, because SHA pinning, the ruleset-bound aggregator name, smoke gating on uploads, and retention-days are each easy to break in a one-line diff that no smoke build exercises, since workflow-only changes are deliberately not smoke-built. WORKFLOW.md keeps authority, and GOVERNANCE.md wins where the two overlap. ---- - -# Workflow CI Contract - -## Why This Exists - -`WORKFLOW.md` in the hub is the largest law doc, a behavioral contract stating required outcomes rather than a required implementation, and it had no skill surface, so agents edited workflow YAML without the contract in view. This skill is the summary plus the binding rules, with the guarantee catalog and the test methodology split into `references/`. `WORKFLOW.md` keeps authority for the contract and methodology, and `GOVERNANCE.md` ("Workflow YAML Conventions", "Release Model") wins where the two overlap. - -## How the Contract Is Read - -- **Outcomes, not bytes.** A workflow is correct when it satisfies the section 4 contract against the expected inputs and outputs, not when it matches a catalog snippet byte for byte. Two repos may implement one guarantee with different YAML. -- **Applicability.** A guarantee governing a construct the repo does not contain is N/A: recorded, excluded from the verdict, never a defect. A source-only pipeline is mostly N/A and that is fine. -- **Operational is binary.** Every applicable guarantee holds, or the workflow is not operational. A single applicable input-output mismatch is a defect regardless of how clean the YAML looks. -- **Reached, not carried.** A standard workflow whose job graph is identical across repos of a type is a `workflow_call` task the hub hosts once, and a repo carries only a caller stub pinned to a hub release commit plus a composite-action hook at `.github/actions/<hook>` for what is its own. The merge-bot is the first, and `docs/reusable-workflows.md` in the hub carries the model, the hook contract, and the phase each workflow migrates in. Until a workflow's phase ships, its copy is graded as below. -- **Two layers.** Orchestration (the PR entry workflow, publisher, version/release/badge jobs) is generic and standard at the job level. Build leaves (`build-<target>-task.yml`) are repo-owned. Inputs like `github`/`nuget`/`dockerhub`/`expect_release_assets` live on the orchestrator, a leaf only receives `ref`/`branch`/`smoke` and a derived `push`, so assert each input in the layer that declares it. What a repo curates is the list of targets, and adding or dropping one edits the whole surface together: the `enable_<target>` input, the `build-<target>` job and its `github-release` `needs:` entry, the `changes` paths-filter entry and output, and the `smoke-build` enable-forward (D6.4). - -## Style Rules That Break in One-Line Diffs - -- **Pin every action to a commit SHA** with a trailing `# vX.Y.Z` comment, first-party included. The one documented no-pin exception is `dotnet/nbgv@master`. Invent no others. -- **Names carry meaning**: `-task.yml` files and "task" names are reusable (`on: workflow_call`), entry points end in what they do and their names end in "action", every job `name:` ends in "job" and every step in "step". A ruleset-bound required check's job `name:` and the ruleset `context:` are one string renamed together, in the live ruleset and `repo-config/{develop,main}.json` in lockstep, or required-check enforcement silently breaks. -- **Concurrency**: top-level workflows use `group: '${{ github.workflow }}-${{ github.ref }}'` with `cancel-in-progress: true`. The publisher is the documented exception: a global ref-independent group with `cancel-in-progress: false`, so publishes serialize and never cancel mid-push. -- **Shells**: every multi-line bash `run:` starts `set -Eeuo pipefail`. Multi-line `if:` uses `>-`, never `|`. -- **Boolean inputs** are declared in both trigger blocks and compared against both forms, `${{ inputs.foo == true || inputs.foo == 'true' }}`, since `workflow_dispatch` delivers strings. -- **Permissions validate before `if:`**, so even a skipped job needs valid `permissions:`, and a callee's extra scope (`actions: write`, `id-token: write`) is granted by the caller at the one entry point that needs it. -- **Chaining across optional jobs** allowlists `success`/`skipped` explicitly, because `!= 'failure'` lets `cancelled` through. -- **Docker layer cache** targets a registry tag (`buildcache-<branch>`), never `type=gha`. -- **Workflow YAML is LF.** Preserve endings on every edit. - -## The Core Behavioral Spine - -- **PRs validate fast and never publish**: a paths-filter smoke-builds only changed targets, a type-appropriate validation job always runs, and one required aggregator gates the merge, treating skipped smoke as pass and blocking on failure or cancelled. Smoke does a full compile/lint/test but pushes nothing and uploads nothing, every `upload-artifact` gated `!smoke`. -- **A human merge never auto-publishes**: a `plan` job decides once and every job gates on it. Publishes come from a code-affecting bot push to `main`, a manual dispatch of `main` or `develop`, or the main-only weekly Docker schedule. Each run builds the one trigger branch, `main` a clean `X.Y.Z`, anything else a prerelease `X.Y.Z-g<sha>`, with NBGV owning the patch from git height. The release tags the built commit's SHA (`GitCommitId`), never a branch name. -- **Validate at entry**: cross-input and input-versus-derived-state invariants are asserted once in a dedicated entry job the downstream jobs `needs:`, failing fast with `::error::` before expensive work. The release gate checks branch-versus-prerelease in both directions, strips `+buildmetadata`, and on smoke skips the check while the job still succeeds. -- **The seam contract**: a target contributes a release file by uploading `release-asset-<branch>-<target>`, and the release job collects by `pattern:` plus `merge-multiple:`, never `artifact-ids:`, canonical even for a single target. A repo with no file target passes `expect_release_assets: false` at the caller. -- **Artifacts are an intra-run handoff**: consume-then-delete at the point of consumption, gated to the consumer's condition, best-effort, `retention-days: 1` on every upload as the backstop, and never a blanket delete of the run's artifact set, which destroys the diagnostics you need when the run fails. -- **No-op republish**: an unchanged version re-pushes nothing, the release-create step skips when the tag exists, registries dedupe server-side (`--skip-duplicate`, `skip-existing: true`), and Docker alone always re-pushes by design. -- **A build failure blocks every publish target**: `github-release` needs every build, and the terminal registry pusher guards with `!failure() && !cancelled()`, so nothing partial ships. - -The full catalog, each guarantee with the failure mode it prevents, is in `references/d-guarantees.md`. Auditing, tracing, and probing a repo's workflows is `references/test-methodology.md`. - -## After Any Workflow Edit - -Workflow-only changes are not smoke-built, so run actionlint locally (the Docker invocation in `GOVERNANCE.md` "Running the Linters Locally", which bundles shellcheck for `run:` blocks) before pushing, and remember a workflow change is only fully exercised by CI, since `secrets: inherit`, `permissions:`, and `needs:` wiring resolve only in a real run. +--- +name: workflow-ci-contract +description: >- + Governs the WORKFLOW.md CI/CD behavioral contract for every ptr727/ProjectTemplate fleet repo: the D1-D9 guarantees stated as the failure mode each prevents, the seam contract for release assets, the artifact lifecycle, NBGV versioning and classification, validate-at-entry, and the 5A/5B/5C test methodology with its per-type walkthroughs. Use this whenever writing or editing anything under .github/workflows/, adding or dropping a release target, auditing a repo's workflows, or reasoning about why a publish did or did not fire. This is the YAML half of the pipeline, and the operational-vs-release-workflow skill keeps the git half (branching, promotion, publish policy), so branch choice questions go there. Triggers even when the edit looks mechanical, such as bumping an action, renaming a job, or adding one upload step, because SHA pinning, the ruleset-bound aggregator name, smoke gating on uploads, and retention-days are each easy to break in a one-line diff that no smoke build exercises, since workflow-only changes are deliberately not smoke-built. WORKFLOW.md keeps authority, and GOVERNANCE.md wins where the two overlap. +--- + +# Workflow CI Contract + +## Why This Exists + +`WORKFLOW.md` in the hub is the largest law doc, a behavioral contract stating required outcomes rather than a required implementation, and it had no skill surface, so agents edited workflow YAML without the contract in view. This skill is the summary plus the binding rules, with the guarantee catalog and the test methodology split into `references/`. `WORKFLOW.md` keeps authority for the contract and methodology, and `GOVERNANCE.md` ("Workflow YAML Conventions", "Release Model") wins where the two overlap. + +## How the Contract Is Read + +- **Outcomes, not bytes.** A workflow is correct when it satisfies the section 4 contract against the expected inputs and outputs, not when it matches a catalog snippet byte for byte. Two repos may implement one guarantee with different YAML. +- **Applicability.** A guarantee governing a construct the repo does not contain is N/A: recorded, excluded from the verdict, never a defect. A source-only pipeline is mostly N/A and that is fine. +- **Operational is binary.** Every applicable guarantee holds, or the workflow is not operational. A single applicable input-output mismatch is a defect regardless of how clean the YAML looks. +- **Reached, not carried.** A standard workflow whose job graph is identical across repos of a type is a `workflow_call` task the hub hosts once, and a repo carries only a caller stub pinned to a hub release commit plus a composite-action hook at `.github/actions/<hook>` for what is its own. The merge-bot is the first, and `docs/reusable-workflows.md` in the hub carries the model, the hook contract, and the phase each workflow migrates in. Until a workflow's phase ships, its copy is graded as below. +- **Two layers.** Orchestration (the PR entry workflow, publisher, version/release/badge jobs) is generic and standard at the job level. Build leaves (`build-<target>-task.yml`) are repo-owned. Inputs like `github`/`nuget`/`dockerhub`/`expect_release_assets` live on the orchestrator, a leaf only receives `ref`/`branch`/`smoke` and a derived `push`, so assert each input in the layer that declares it. What a repo curates is the list of targets, and adding or dropping one edits the whole surface together: the `enable_<target>` input, the `build-<target>` job and its `github-release` `needs:` entry, the `changes` paths-filter entry and output, and the `smoke-build` enable-forward (D6.4). + +## Style Rules That Break in One-Line Diffs + +- **Pin every action to a commit SHA** with a trailing `# vX.Y.Z` comment, first-party included. The one documented no-pin exception is `dotnet/nbgv@master`. Invent no others. +- **Names carry meaning**: `-task.yml` files and "task" names are reusable (`on: workflow_call`), entry points end in what they do and their names end in "action", every job `name:` ends in "job" and every step in "step". A ruleset-bound required check's job `name:` and the ruleset `context:` are one string renamed together, in the live ruleset and `repo-config/{develop,main}.json` in lockstep, or required-check enforcement silently breaks. +- **Concurrency**: top-level workflows use `group: '${{ github.workflow }}-${{ github.ref }}'` with `cancel-in-progress: true`. The publisher is the documented exception: a global ref-independent group with `cancel-in-progress: false`, so publishes serialize and never cancel mid-push. +- **Shells**: every multi-line bash `run:` starts `set -Eeuo pipefail`. Multi-line `if:` uses `>-`, never `|`. +- **Boolean inputs** are declared in both trigger blocks and compared against both forms, `${{ inputs.foo == true || inputs.foo == 'true' }}`, since `workflow_dispatch` delivers strings. +- **Permissions validate before `if:`**, so even a skipped job needs valid `permissions:`, and a callee's extra scope (`actions: write`, `id-token: write`) is granted by the caller at the one entry point that needs it. +- **Chaining across optional jobs** allowlists `success`/`skipped` explicitly, because `!= 'failure'` lets `cancelled` through. +- **Docker layer cache** targets a registry tag (`buildcache-<branch>`), never `type=gha`. +- **Workflow YAML is LF.** Preserve endings on every edit. + +## The Core Behavioral Spine + +- **PRs validate fast and never publish**: a paths-filter smoke-builds only changed targets, a type-appropriate validation job always runs, and one required aggregator gates the merge, treating skipped smoke as pass and blocking on failure or cancelled. Smoke does a full compile/lint/test but pushes nothing and uploads nothing, every `upload-artifact` gated `!smoke`. +- **A human merge never auto-publishes**: a `plan` job decides once and every job gates on it. Publishes come from a code-affecting bot push to `main`, a manual dispatch of `main` or `develop`, or the main-only weekly Docker schedule. Each run builds the one trigger branch, `main` a clean `X.Y.Z`, anything else a prerelease `X.Y.Z-g<sha>`, with NBGV owning the patch from git height. The release tags the built commit's SHA (`GitCommitId`), never a branch name. +- **Validate at entry**: cross-input and input-versus-derived-state invariants are asserted once in a dedicated entry job the downstream jobs `needs:`, failing fast with `::error::` before expensive work. The release gate checks branch-versus-prerelease in both directions, strips `+buildmetadata`, and on smoke skips the check while the job still succeeds. +- **The seam contract**: a target contributes a release file by uploading `release-asset-<branch>-<target>`, and the release job collects by `pattern:` plus `merge-multiple:`, never `artifact-ids:`, canonical even for a single target. A repo with no file target passes `expect_release_assets: false` at the caller. +- **Artifacts are an intra-run handoff**: consume-then-delete at the point of consumption, gated to the consumer's condition, best-effort, `retention-days: 1` on every upload as the backstop, and never a blanket delete of the run's artifact set, which destroys the diagnostics you need when the run fails. +- **No-op republish**: an unchanged version re-pushes nothing, the release-create step skips when the tag exists, registries dedupe server-side (`--skip-duplicate`, `skip-existing: true`), and Docker alone always re-pushes by design. +- **A build failure blocks every publish target**: `github-release` needs every build, and the terminal registry pusher guards with `!failure() && !cancelled()`, so nothing partial ships. + +The full catalog, each guarantee with the failure mode it prevents, is in `references/d-guarantees.md`. Auditing, tracing, and probing a repo's workflows is `references/test-methodology.md`. + +## After Any Workflow Edit + +Workflow-only changes are not smoke-built, so run actionlint locally (the Docker invocation in `GOVERNANCE.md` "Running the Linters Locally", which bundles shellcheck for `run:` blocks) before pushing, and remember a workflow change is only fully exercised by CI, since `secrets: inherit`, `permissions:`, and `needs:` wiring resolve only in a real run. diff --git a/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/d-guarantees.md b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/d-guarantees.md index 4c3d5cc2..b41c99a6 100644 --- a/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/d-guarantees.md +++ b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/d-guarantees.md @@ -1,70 +1,70 @@ -# The D-Guarantees, Condensed - -Each guarantee is a MUST from `WORKFLOW.md` section 4, stated as input to output plus the failure mode it prevents. This is the condensed catalog for working from, and `WORKFLOW.md` keeps authority, so read the section there when a guarantee's exact wording decides a verdict. - -## D1: PR Fast-Feedback (Smoke) - -- **D1.1** Only changed targets build: each target has a paths-filter entry, unchanged targets skip. Prevents a changed target slipping through unbuilt. -- **D1.2** A validation job always runs on any PR, and a non-.NET repo replaces it (never deletes it), re-pointing every `needs:` on it, the aggregator and `smoke-build` both. Prevents a PR merging with no validation, or a dangling `needs:` failing the workflow to load. -- **D1.3** Smoke never publishes and never uploads: full compile/lint/test, no pushes, every `upload-artifact` gated `!smoke`. Prevents a PR publishing and orphaned artifacts. -- **D1.4** Workflow-file changes are not smoke-built (the filter excludes `.github/workflows/**`), actionlint still validates them. -- **D1.5** One required aggregator gates merge: `needs:` the changes and validation jobs, passes on skipped smoke, blocks on failure or cancelled, and its name is ruleset-bound (job `name:` equals ruleset `context:`, renamed together). -- **D1.6** Coverage reports to Codecov for C# and Python repos with tests, best-effort so an outage never reds the gate, with a `codecov.yml` setting statuses informational and `.gitignore` excluding coverage output. - -## D2: Validation at Entry - -- **D2.1** A dedicated entry job asserts each cross-input invariant before expensive work, downstream jobs `needs:` it. -- **D2.2** The release gate fails loud when the default branch carries a prerelease suffix or a non-default branch carries none, strips `+buildmetadata` first, and on smoke skips the check while the job still succeeds (a job-level `if:` would skip dependents with it). -- **D2.3** A dispatch publish from any ref other than `main` or `develop` fails fast. -- **D2.4** Mutually-exclusive or must-pair inputs are validated, a half-filled combination fails fast. - -## D3: Versioning and Classification - -- **D3.1** One branch per run: `github.ref` names the built branch, NBGV classifies it directly, no `IGNORE_GITHUB_REF`. -- **D3.2** Default branch yields `X.Y.Z`, every other branch `X.Y.Z-g<sha>`, and the default-branch literal in the gate, the `prerelease` expression, and `version.json`'s `publicReleaseRefSpec` all name the repo's real default branch. -- **D3.3** `version.json` sets the major.minor floor, NBGV appends git height as the patch, and both are retained even by a no-compiler repo, since they own the tag. -- **D3.4** Registry versions follow the classification per registry: NuGet.org derives prerelease from the SemVer2 suffix, PyPI builds from `AssemblyFileVersion` with `.dev0` appended on `develop` only, and the develop build stays `--pre`-selectable above the released version. -- **D3.5** A wrapper repo drives its image version from a committed `name -> version` state file, and the leaf must actually read it, since a leaf still tagging off NBGV means the wrapper is not pinned to upstream. - -## D4: Release and Publish - -- **D4.1** Gated single-branch publish: a human merge never auto-publishes, the `plan` job decides once, publishes come from a code-affecting bot push to `main`, a dispatch of `main`/`develop`, or the main-only weekly Docker schedule. -- **D4.2** `target_commitish` is the built commit's SHA (NBGV `GitCommitId`), never a branch name and never `github.sha`. -- **D4.3** Every release is a tag plus source zip, README, and LICENSE, file targets attach `release-asset-*`, and a no-file-target caller passes `expect_release_assets: false` or the release-create step fails on unmatched files. -- **D4.4** No-op republish: an unchanged version re-pushes nothing, the release-create skips when the tag exists (refreshed only on `workflow_dispatch`), registries dedupe server-side, and Docker always re-pushes by design. -- **D4.5** A failed build blocks every publish target: `github-release` needs every build, the terminal registry pusher guards `!failure() && !cancelled()`, so nothing partial ships. -- **D4.6** A deploy check asserts which release and which environment answer, waiting for convergence to a bounded timeout, with an unreachable host reported distinctly from an HTTP status. - -## D5: Resource Cleanup - -- **D5.1** A cross-job transfer artifact is deleted at its point of consumption. An in-run intermediate may rely on the retention backstop. -- **D5.2** The delete runs under the same condition as its consumer, so a no-op re-run skips the release-asset delete while the PyPI build-artifact delete still runs. -- **D5.3** Cleanup is best-effort (`continue-on-error`, tolerate a failed listing, delete all matching ids). -- **D5.4** Every `upload-artifact` sets `retention-days: 1`. -- **D5.5** Never blanket-delete the run's artifacts, which destroys diagnostics and auto-emitted build records. -- **D5.6** A durable deploy destination's retention is bounded by a declared count with one side recorded as owning the prune: the deploy where its credential can observe the destination, the host where the credential is deliberately write-only. - -## D6: Seam Conformance - -- **D6.1** The release job downloads by `pattern:`/`merge-multiple:`, never `artifact-ids:`, canonical for single-target repos too. -- **D6.2** Branch-derived config reads `inputs.branch`, never `github.ref_name`. -- **D6.3** Artifact names are branch-suffixed. -- **D6.4** A target add or drop updates the whole surface together: `enable_<target>` input, `build-<target>` job, `github-release` `needs:` entry, paths-filter entry and output, and the `smoke-build` enable-forward. - -## D7: Concurrency, Permissions, Safety - -- **D7.1** The publisher serializes: global ref-independent concurrency group, `cancel-in-progress: false`. -- **D7.2** Every reusable job declares valid `permissions:` (validated before `if:`), a callee's extra scope granted by the caller. -- **D7.3** Boolean inputs are declared in both trigger blocks and compared against both forms. -- **D7.4** Optional-dependency chaining allowlists `success`/`skipped` explicitly. - -## D8: Bots and Automation - -- **D8.1** The merge-bot enables auto-merge on `opened`/`reopened` for every Dependabot tier, dispatches squash or merge by base ref, disables on a maintainer-pushed `synchronize`, and keys concurrency on the PR number, not `github.ref`. -- **D8.2** Codegen runs a deterministic matrix over both branches, Dependabot targets both branches. -- **D8.3** The upstream tracker writes a committed `name -> version` state file via a rolling per-branch bump PR the merge-bot auto-merges, and its branch prefix must match the merge-bot's head-ref pairs or auto-merge silently never fires. -- **D8.4** An identity allowlist used as a gate emits a `::warning::` on the non-matching branch rather than falling through silently, since a renamed App slug otherwise turns the gate off invisibly. - -## D9: Style and Static - -SHA pins with version comments, the name-suffix rules, `set -Eeuo pipefail`, `if: >-`, registry-tag Docker cache with `cache-to` only the built branch on push and `cache-from` both branches, line endings per `.editorconfig`. +# The D-Guarantees, Condensed + +Each guarantee is a MUST from `WORKFLOW.md` section 4, stated as input to output plus the failure mode it prevents. This is the condensed catalog for working from, and `WORKFLOW.md` keeps authority, so read the section there when a guarantee's exact wording decides a verdict. + +## D1: PR Fast-Feedback (Smoke) + +- **D1.1** Only changed targets build: each target has a paths-filter entry, unchanged targets skip. Prevents a changed target slipping through unbuilt. +- **D1.2** A validation job always runs on any PR, and a non-.NET repo replaces it (never deletes it), re-pointing every `needs:` on it, the aggregator and `smoke-build` both. Prevents a PR merging with no validation, or a dangling `needs:` failing the workflow to load. +- **D1.3** Smoke never publishes and never uploads: full compile/lint/test, no pushes, every `upload-artifact` gated `!smoke`. Prevents a PR publishing and orphaned artifacts. +- **D1.4** Workflow-file changes are not smoke-built (the filter excludes `.github/workflows/**`), actionlint still validates them. +- **D1.5** One required aggregator gates merge: `needs:` the changes and validation jobs, passes on skipped smoke, blocks on failure or cancelled, and its name is ruleset-bound (job `name:` equals ruleset `context:`, renamed together). +- **D1.6** Coverage reports to Codecov for C# and Python repos with tests, best-effort so an outage never reds the gate, with a `codecov.yml` setting statuses informational and `.gitignore` excluding coverage output. + +## D2: Validation at Entry + +- **D2.1** A dedicated entry job asserts each cross-input invariant before expensive work, downstream jobs `needs:` it. +- **D2.2** The release gate fails loud when the default branch carries a prerelease suffix or a non-default branch carries none, strips `+buildmetadata` first, and on smoke skips the check while the job still succeeds (a job-level `if:` would skip dependents with it). +- **D2.3** A dispatch publish from any ref other than `main` or `develop` fails fast. +- **D2.4** Mutually-exclusive or must-pair inputs are validated, a half-filled combination fails fast. + +## D3: Versioning and Classification + +- **D3.1** One branch per run: `github.ref` names the built branch, NBGV classifies it directly, no `IGNORE_GITHUB_REF`. +- **D3.2** Default branch yields `X.Y.Z`, every other branch `X.Y.Z-g<sha>`, and the default-branch literal in the gate, the `prerelease` expression, and `version.json`'s `publicReleaseRefSpec` all name the repo's real default branch. +- **D3.3** `version.json` sets the major.minor floor, NBGV appends git height as the patch, and both are retained even by a no-compiler repo, since they own the tag. +- **D3.4** Registry versions follow the classification per registry: NuGet.org derives prerelease from the SemVer2 suffix, PyPI builds from `AssemblyFileVersion` with `.dev0` appended on `develop` only, and the develop build stays `--pre`-selectable above the released version. +- **D3.5** A wrapper repo drives its image version from a committed `name -> version` state file, and the leaf must actually read it, since a leaf still tagging off NBGV means the wrapper is not pinned to upstream. + +## D4: Release and Publish + +- **D4.1** Gated single-branch publish: a human merge never auto-publishes, the `plan` job decides once, publishes come from a code-affecting bot push to `main`, a dispatch of `main`/`develop`, or the main-only weekly Docker schedule. +- **D4.2** `target_commitish` is the built commit's SHA (NBGV `GitCommitId`), never a branch name and never `github.sha`. +- **D4.3** Every release is a tag plus source zip, README, and LICENSE, file targets attach `release-asset-*`, and a no-file-target caller passes `expect_release_assets: false` or the release-create step fails on unmatched files. +- **D4.4** No-op republish: an unchanged version re-pushes nothing, the release-create skips when the tag exists (refreshed only on `workflow_dispatch`), registries dedupe server-side, and Docker always re-pushes by design. +- **D4.5** A failed build blocks every publish target: `github-release` needs every build, the terminal registry pusher guards `!failure() && !cancelled()`, so nothing partial ships. +- **D4.6** A deploy check asserts which release and which environment answer, waiting for convergence to a bounded timeout, with an unreachable host reported distinctly from an HTTP status. + +## D5: Resource Cleanup + +- **D5.1** A cross-job transfer artifact is deleted at its point of consumption. An in-run intermediate may rely on the retention backstop. +- **D5.2** The delete runs under the same condition as its consumer, so a no-op re-run skips the release-asset delete while the PyPI build-artifact delete still runs. +- **D5.3** Cleanup is best-effort (`continue-on-error`, tolerate a failed listing, delete all matching ids). +- **D5.4** Every `upload-artifact` sets `retention-days: 1`. +- **D5.5** Never blanket-delete the run's artifacts, which destroys diagnostics and auto-emitted build records. +- **D5.6** A durable deploy destination's retention is bounded by a declared count with one side recorded as owning the prune: the deploy where its credential can observe the destination, the host where the credential is deliberately write-only. + +## D6: Seam Conformance + +- **D6.1** The release job downloads by `pattern:`/`merge-multiple:`, never `artifact-ids:`, canonical for single-target repos too. +- **D6.2** Branch-derived config reads `inputs.branch`, never `github.ref_name`. +- **D6.3** Artifact names are branch-suffixed. +- **D6.4** A target add or drop updates the whole surface together: `enable_<target>` input, `build-<target>` job, `github-release` `needs:` entry, paths-filter entry and output, and the `smoke-build` enable-forward. + +## D7: Concurrency, Permissions, Safety + +- **D7.1** The publisher serializes: global ref-independent concurrency group, `cancel-in-progress: false`. +- **D7.2** Every reusable job declares valid `permissions:` (validated before `if:`), a callee's extra scope granted by the caller. +- **D7.3** Boolean inputs are declared in both trigger blocks and compared against both forms. +- **D7.4** Optional-dependency chaining allowlists `success`/`skipped` explicitly. + +## D8: Bots and Automation + +- **D8.1** The merge-bot enables auto-merge on `opened`/`reopened` for every Dependabot tier, dispatches squash or merge by base ref, disables on a maintainer-pushed `synchronize`, and keys concurrency on the PR number, not `github.ref`. +- **D8.2** Codegen runs a deterministic matrix over both branches, Dependabot targets both branches. +- **D8.3** The upstream tracker writes a committed `name -> version` state file via a rolling per-branch bump PR the merge-bot auto-merges, and its branch prefix must match the merge-bot's head-ref pairs or auto-merge silently never fires. +- **D8.4** An identity allowlist used as a gate emits a `::warning::` on the non-matching branch rather than falling through silently, since a renamed App slug otherwise turns the gate off invisibly. + +## D9: Style and Static + +SHA pins with version comments, the name-suffix rules, `set -Eeuo pipefail`, `if: >-`, registry-tag Docker cache with `cache-to` only the built branch on push and `cache-from` both branches, line endings per `.editorconfig`. diff --git a/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/test-methodology.md b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/test-methodology.md index 3f76f114..ecd09487 100644 --- a/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/test-methodology.md +++ b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/test-methodology.md @@ -1,27 +1,27 @@ -# Testing a Repo's Workflows - -The three escalating verification modes from `WORKFLOW.md` section 5, which keeps authority. N/A items (a check or scenario for an absent construct) are recorded and excluded, never failed. - -## 5A: Static Audit - -Read the workflow files plus `version.json` and assert the structural fact behind each applicable D-guarantee, each pass, fail, or N/A with a `file:line` citation, asserting each input in the layer that declares it. The core sweep covers: the paths-filter's target coverage and `.github/workflows/**` exclusion, smoke gating on every upload, the aggregator's `needs:` and skip/fail handling, the entry validation jobs and the two-directional release gate, the single-branch NBGV classification and the three default-branch literals agreeing, `target_commitish` from `GitCommitId`, the consume-then-delete artifact lifecycle with `retention-days: 1` everywhere and no blanket delete, the `pattern:` handoff and `inputs.branch` config, the publisher's serialized concurrency, and the SHA pins. `WORKFLOW.md` 5A lists the per-type addenda (console runtime matrix, NuGet `--skip-duplicate`, the PyPI OIDC environment split, Docker `expect_release_assets` and cache shape, the static-site deploy gates), so apply only the ones the repo's types imply. - -## 5B: Trace Scenarios - -For each applicable scenario, evaluate every job's `if:`/`needs:` against the inputs and compare the predicted run/skip, version, release, and artifact end state to the expected table in `WORKFLOW.md` 5B. The load-bearing ones: - -- **S1** a PR touching a target: that target smoke-builds, nothing uploads, the aggregator succeeds. -- **S5/S6** a bot push to `main`: publishes only when code-affecting, and a human push never does. -- **S7** a publish run builds the one trigger branch with the right classification and leaves no dangling artifacts. -- **S8** a dispatch from a ref other than `main`/`develop` fails fast. -- **S9** a no-op re-run: release-create skipped, registries dedupe, PyPI build artifact still deleted, Docker still re-pushes. -- **S10** branch and version classification disagree: the gate fails loud and everything downstream skips. -- **S12/S13** a deploy dispatch: ref gate first, environment re-asserted, pointer flip separate, live check names the release, and a production deploy from a non-default ref fails before anything is written. - -## 5C: Live Probe - -Only for what a static trace cannot settle: a trivial PR to confirm S1, a smoke push-probe of both branches' version classification, registry queries after a real publish, and the artifact lifecycle read from a real run's logs. The deploy ref gate is verified only by tripping it, and that dispatch is the maintainer's to run: the agent prepares the command and reads back the four evidence items (gate conclusion, its error text, every downstream job skipped, deployment count unchanged), and a harness refusal to fire it is the control working, never something to re-shape. - -## Verdict - -Operational iff every applicable 5A item passes and every applicable 5B scenario matches, with the failing guarantees and their triggering inputs named, and the N/A list recorded. Per-project-type walkthroughs mapping scenarios onto targets, including source-only, static-site, and operational shapes, are `WORKFLOW.md` section 6. +# Testing a Repo's Workflows + +The three escalating verification modes from `WORKFLOW.md` section 5, which keeps authority. N/A items (a check or scenario for an absent construct) are recorded and excluded, never failed. + +## 5A: Static Audit + +Read the workflow files plus `version.json` and assert the structural fact behind each applicable D-guarantee, each pass, fail, or N/A with a `file:line` citation, asserting each input in the layer that declares it. The core sweep covers: the paths-filter's target coverage and `.github/workflows/**` exclusion, smoke gating on every upload, the aggregator's `needs:` and skip/fail handling, the entry validation jobs and the two-directional release gate, the single-branch NBGV classification and the three default-branch literals agreeing, `target_commitish` from `GitCommitId`, the consume-then-delete artifact lifecycle with `retention-days: 1` everywhere and no blanket delete, the `pattern:` handoff and `inputs.branch` config, the publisher's serialized concurrency, and the SHA pins. `WORKFLOW.md` 5A lists the per-type addenda (console runtime matrix, NuGet `--skip-duplicate`, the PyPI OIDC environment split, Docker `expect_release_assets` and cache shape, the static-site deploy gates), so apply only the ones the repo's types imply. + +## 5B: Trace Scenarios + +For each applicable scenario, evaluate every job's `if:`/`needs:` against the inputs and compare the predicted run/skip, version, release, and artifact end state to the expected table in `WORKFLOW.md` 5B. The load-bearing ones: + +- **S1** a PR touching a target: that target smoke-builds, nothing uploads, the aggregator succeeds. +- **S5/S6** a bot push to `main`: publishes only when code-affecting, and a human push never does. +- **S7** a publish run builds the one trigger branch with the right classification and leaves no dangling artifacts. +- **S8** a dispatch from a ref other than `main`/`develop` fails fast. +- **S9** a no-op re-run: release-create skipped, registries dedupe, PyPI build artifact still deleted, Docker still re-pushes. +- **S10** branch and version classification disagree: the gate fails loud and everything downstream skips. +- **S12/S13** a deploy dispatch: ref gate first, environment re-asserted, pointer flip separate, live check names the release, and a production deploy from a non-default ref fails before anything is written. + +## 5C: Live Probe + +Only for what a static trace cannot settle: a trivial PR to confirm S1, a smoke push-probe of both branches' version classification, registry queries after a real publish, and the artifact lifecycle read from a real run's logs. The deploy ref gate is verified only by tripping it, and that dispatch is the maintainer's to run: the agent prepares the command and reads back the four evidence items (gate conclusion, its error text, every downstream job skipped, deployment count unchanged), and a harness refusal to fire it is the control working, never something to re-shape. + +## Verdict + +Operational iff every applicable 5A item passes and every applicable 5B scenario matches, with the failing guarantees and their triggering inputs named, and the N/A list recorded. Per-project-type walkthroughs mapping scenarios onto targets, including source-only, static-site, and operational shapes, are `WORKFLOW.md` section 6. diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 81858064..2db60353 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,14 +1,14 @@ -{ - "name": "projecttemplate-fleet", - "description": "Fleet-wide agent rules for the ptr727 repos, packaged as Claude Code Skills.", - "owner": { - "name": "ptr727" - }, - "plugins": [ - { - "name": "fleet-skills", - "description": "Fleet-wide agent rules (comment style, PR-review conduct, resync safety) packaged as Claude Code Skills.", - "source": "./.claude-plugin/fleet-skills" - } - ] -} +{ + "name": "projecttemplate-fleet", + "description": "Fleet-wide agent rules for the ptr727 repos, packaged as Claude Code Skills.", + "owner": { + "name": "ptr727" + }, + "plugins": [ + { + "name": "fleet-skills", + "description": "Fleet-wide agent rules (comment style, PR-review conduct, resync safety) packaged as Claude Code Skills.", + "source": "./.claude-plugin/fleet-skills" + } + ] +} diff --git a/.editorconfig b/.editorconfig index 1dc93b7b..96024f03 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,264 +1,211 @@ -# https://editorconfig.org - -# https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/identifier-names -# https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/coding-conventions -# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/overview - -# https://github.com/dotnet/runtime/blob/main/docs/coding-guidelines/coding-style.md -# https://github.com/dotnet/runtime/blob/main/.editorconfig - -# https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-format -# Verify with: dotnet format style --verify-no-changes --severity=info --verbosity=detailed - -# Root config -root = true - -# Defaults: CRLF is the default, and only the LF exceptions below are declared. -# Most LF pins are shared with `.gitattributes`, which git enforces: `*.sh`, the husky pre-commit shebang, `spec/validate.py`, `uv.lock`, and Dockerfiles. -# The workflow-YAML pins (`.github/workflows/*` and the catalog snippet workflows) are `.editorconfig`-only. -# For those, git stays passive (`* -text`) and CI (editorconfig-checker) enforces LF. -# Keep the `[*]` `end_of_line = crlf` default, which the Windows-GUI and WSL-engine workflow requires because Windows tooling misbehaves on LF. -# Every uncovered file type relies on that default too. -[*] -charset = utf-8 -end_of_line = crlf -indent_size = 4 -indent_style = space -insert_final_newline = true -trim_trailing_whitespace = true - -# Markdown files -[*.md] -trim_trailing_whitespace = false - -# Xml files -[*.{xml,csproj,props,targets}] -indent_size = 2 - -# Yaml files -[*.{yml,yaml}] -indent_size = 2 - -# Workflow YAML is LF, because Dependabot and Actions rewrite it with LF, so declaring LF keeps it consistent instead of mixed. -# Endings are still left alone by git (`* -text`), and this file plus CI (editorconfig-checker) enforce it. -# Other YAML stays CRLF. -[.github/workflows/*.{yml,yaml}] -end_of_line = lf - -# Composite action metadata is Actions-owned like the workflows above, and Dependabot rewrites the -# `uses:` pins inside it with LF exactly as it does theirs, so declaring LF keeps it consistent. -[.github/actions/**/*.{yml,yaml}] -end_of_line = lf - -# Catalog snippet workflows mirror the real workflow files pinned LF above. -# Keep the snippets LF, so a copied snippet lands compliant instead of needing conversion. -[catalog/snippets/workflows/*.{yml,yaml}] -end_of_line = lf - -# The husky pre-commit snippet is a shebang script, so keep it LF. -# A copied snippet then runs, where a CRLF shebang would break it. -[catalog/snippets/husky/pre-commit] -end_of_line = lf - -# This repository's own hook, paired with the `.gitattributes` pin. -# The git pin alone leaves the editor free to write a CRLF shebang, which would break it. -[.husky/pre-commit] -end_of_line = lf - -# Linux scripts -[*.sh] -end_of_line = lf - -# Python is CRLF by the `[*]` default, since universal newlines accept it and it is commonly edited on Windows. -# Pin LF only for a `.py` executed directly via its shebang, by path. -# Here that is the CI validation entry point and the fleet-audit runner. -[spec/{validate,audit,fidelity_honesty,workflow_reuse}.py] -end_of_line = lf - -# The agent-safety kit's Python is shebang-executable tooling run by path, namely the PreToolUse hook and its installer. -# Pin LF for the same reason as the entry points above, because a CRLF shebang breaks direct execution on a Unix host. -[host-setup/agent-safety/*.py] -end_of_line = lf - -# The repo lint and review scripts, and their tests under scripts/tests/, are shebang-executable tooling run by path, so a CRLF shebang would break direct execution on a Unix host. -# They are pinned in .gitattributes to match. -# `**` rather than `*`, because an EditorConfig `*` stops at a path separator and would leave scripts/tests/ on the CRLF default. -[scripts/**.py] -end_of_line = lf - -# The uv tool regenerates uv.lock with LF on every platform, so pin it here. -# Without the pin an EOL check (editorconfig-checker or CI) fails on every `uv lock` or `uv sync` until the file is manually reconverted. -# The rationale matches the shebang and Dockerfile pins, where a tool owns the ending. -# A Python repo on the CRLF default carries this, and a repo with no lockfile is unaffected. -[uv.lock] -end_of_line = lf - -# Dockerfiles are LF, because CRLF breaks RUN heredocs and line continuations. -[{Dockerfile,*.Dockerfile}] -end_of_line = lf - -# .NET-only below, covering C# and ReSharper style. -# Everything above is the line-ending governance every derived repo carries, and a non-.NET repo may drop from here down. - -# C# files -[*.cs] -# Suppressions follow CODESTYLE.md "Analyzer Diagnostics and Suppressions". -# Prefer a [SuppressMessage] attribute, or the owning project's .editorconfig. -# Relax a rule repo-wide here only when it applies to every project, never for a brownfield batch. -dotnet_diagnostic.IDE0055.severity = none -csharp_indent_block_contents = true -csharp_indent_braces = false -csharp_indent_case_contents = true -csharp_indent_case_contents_when_block = false -csharp_indent_labels = one_less_than_current -csharp_indent_switch_labels = true -csharp_new_line_before_catch = true -csharp_new_line_before_else = true -csharp_new_line_before_finally = true -csharp_new_line_before_members_in_anonymous_types = true -csharp_new_line_before_members_in_object_initializers = true -csharp_new_line_before_open_brace = all -csharp_new_line_between_query_expression_clauses = true -csharp_prefer_braces = true -csharp_prefer_simple_default_expression = true -csharp_prefer_simple_using_statement = true -csharp_prefer_static_anonymous_function = true -csharp_prefer_static_local_function = true -csharp_prefer_system_threading_lock = true -csharp_preferred_modifier_order = public,private,protected,internal,file,static,abstract,sealed,virtual,override,readonly,unsafe,volatile,async,extern,new,partial:warning -csharp_preserve_single_line_blocks = true -csharp_preserve_single_line_statements = false -csharp_space_after_cast = false -csharp_space_after_colon_in_inheritance_clause = true -csharp_space_after_comma = true -csharp_space_after_dot = false -csharp_space_after_keywords_in_control_flow_statements = true -csharp_space_after_semicolon_in_for_statement = true -csharp_space_around_binary_operators = before_and_after -csharp_space_around_declaration_statements = false -csharp_space_before_colon_in_inheritance_clause = true -csharp_space_before_comma = false -csharp_space_before_dot = false -csharp_space_before_open_square_brackets = false -csharp_space_before_semicolon_in_for_statement = false -csharp_space_between_empty_square_brackets = false -csharp_space_between_method_call_empty_parameter_list_parentheses = false -csharp_space_between_method_call_name_and_opening_parenthesis = false -csharp_space_between_method_call_parameter_list_parentheses = false -csharp_space_between_method_declaration_empty_parameter_list_parentheses = false -csharp_space_between_method_declaration_name_and_open_parenthesis = false -csharp_space_between_method_declaration_parameter_list_parentheses = false -csharp_space_between_parentheses = false -csharp_space_between_square_brackets = false -csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true -csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true -csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true -csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true -csharp_style_allow_embedded_statements_on_same_line_experimental = true -csharp_style_conditional_delegate_call = true -csharp_style_deconstructed_variable_declaration = true -csharp_style_expression_bodied_accessors = true -csharp_style_expression_bodied_constructors = true -csharp_style_expression_bodied_indexers = true -csharp_style_expression_bodied_lambdas = true -csharp_style_expression_bodied_local_functions = true -csharp_style_expression_bodied_methods = true -csharp_style_expression_bodied_operators = true -csharp_style_expression_bodied_properties = true -csharp_style_implicit_object_creation_when_type_is_apparent = true -csharp_style_inlined_variable_declaration = true -csharp_style_namespace_declarations = file_scoped -csharp_style_pattern_matching_over_as_with_null_check = true -csharp_style_pattern_matching_over_is_with_cast_check = true -csharp_style_prefer_extended_property_pattern = true -csharp_style_prefer_implicitly_typed_lambda_expression = true -csharp_style_prefer_index_operator = true -csharp_style_prefer_local_over_anonymous_function = true -csharp_style_prefer_method_group_conversion = true -csharp_style_prefer_not_pattern = true -csharp_style_prefer_null_check_over_type_check = true -csharp_style_prefer_pattern_matching = true -csharp_style_prefer_primary_constructors = true -csharp_style_prefer_range_operator = true -csharp_style_prefer_readonly_struct = true -csharp_style_prefer_readonly_struct_member = true -csharp_style_prefer_switch_expression = true -csharp_style_prefer_top_level_statements = true -csharp_style_prefer_tuple_swap = true -csharp_style_prefer_unbound_generic_type_in_nameof = true -csharp_style_prefer_utf8_string_literals = true -csharp_style_throw_expression = true -csharp_style_unused_value_assignment_preference = discard_variable -csharp_style_unused_value_expression_statement_preference = discard_variable -csharp_style_var_elsewhere = false -csharp_style_var_for_built_in_types = false -csharp_style_var_when_type_is_apparent = false -csharp_using_directive_placement = outside_namespace -dotnet_code_quality_unused_parameters = all -dotnet_hide_advanced_members = false -dotnet_member_insertion_location = with_other_members_of_the_same_kind -dotnet_naming_rule.camel_case_for_private_internal_fields.severity = suggestion -dotnet_naming_rule.camel_case_for_private_internal_fields.style = camel_case_underscore_style -dotnet_naming_rule.camel_case_for_private_internal_fields.symbols = private_internal_fields -dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion -dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style -dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields -dotnet_naming_rule.static_fields_should_have_prefix.severity = suggestion -dotnet_naming_rule.static_fields_should_have_prefix.style = static_prefix_style -dotnet_naming_rule.static_fields_should_have_prefix.symbols = static_fields -dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case -dotnet_naming_style.camel_case_underscore_style.required_prefix = _ -dotnet_naming_style.pascal_case_style.capitalization = pascal_case -dotnet_naming_style.static_prefix_style.capitalization = camel_case -dotnet_naming_style.static_prefix_style.required_prefix = s_ -dotnet_naming_symbols.constant_fields.applicable_kinds = field -dotnet_naming_symbols.constant_fields.required_modifiers = const -dotnet_naming_symbols.private_internal_fields.applicable_accessibilities = private, internal -dotnet_naming_symbols.private_internal_fields.applicable_kinds = field -dotnet_naming_symbols.static_fields.applicable_accessibilities = private, internal, private_protected -dotnet_naming_symbols.static_fields.applicable_kinds = field -dotnet_naming_symbols.static_fields.required_modifiers = static -dotnet_prefer_system_hash_code = true -dotnet_property_generation_behavior = prefer_throwing_properties -dotnet_remove_unnecessary_suppression_exclusions = none -dotnet_search_reference_assemblies = true -dotnet_separate_import_directive_groups = false -dotnet_sort_system_directives_first = true -dotnet_style_allow_multiple_blank_lines_experimental = true -dotnet_style_allow_statement_immediately_after_block_experimental = true -dotnet_style_coalesce_expression = true -dotnet_style_collection_initializer = true -dotnet_style_explicit_tuple_names = true -dotnet_style_namespace_match_folder = true -dotnet_style_null_propagation = true -dotnet_style_object_initializer = true -dotnet_style_operator_placement_when_wrapping = beginning_of_line -dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity -dotnet_style_parentheses_in_other_binary_operators = always_for_clarity -dotnet_style_parentheses_in_other_operators = never_if_unnecessary -dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity -dotnet_style_predefined_type_for_locals_parameters_members = true -dotnet_style_predefined_type_for_member_access = true -dotnet_style_prefer_auto_properties = true -dotnet_style_prefer_collection_expression = when_types_loosely_match -dotnet_style_prefer_compound_assignment = true -dotnet_style_prefer_conditional_expression_over_assignment = true -dotnet_style_prefer_conditional_expression_over_return = true -dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed -dotnet_style_prefer_inferred_anonymous_type_member_names = true -dotnet_style_prefer_inferred_tuple_names = true -dotnet_style_prefer_is_null_check_over_reference_equality_method = true -dotnet_style_prefer_simplified_boolean_expressions = true -dotnet_style_prefer_simplified_interpolation = true -dotnet_style_qualification_for_event = false -dotnet_style_qualification_for_field = false -dotnet_style_qualification_for_method = false -dotnet_style_qualification_for_property = false -dotnet_style_readonly_field = true -dotnet_style_require_accessibility_modifiers = for_non_interface_members - -# ReSharper settings -resharper_csharp_trailing_comma_in_multiline_lists = true -resharper_csharp_var_for_built_in_types = false -resharper_csharp_var_when_type_is_apparent = false -resharper_csharp_var_when_type_is_not_apparent = false +# https://editorconfig.org + +# https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/identifier-names +# https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/coding-conventions +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/overview + +# https://github.com/dotnet/runtime/blob/main/docs/coding-guidelines/coding-style.md +# https://github.com/dotnet/runtime/blob/main/.editorconfig + +# https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-format +# Verify with: dotnet format style --verify-no-changes --severity=info --verbosity=detailed + +# Root config +root = true + +# Defaults: LF is the default, and only the CRLF exception below is declared. +# Windows batch and command scripts need CRLF, cmd.exe's line handling is unreliable on LF. +# Everything else is LF, including a file a tool or a Windows GUI editor writes. +# VS Code, Visual Studio, Notepad, and WordPad all read and write LF cleanly on Windows. +# The redundant per-type LF rules that used to sit below this default (shell, Dockerfiles, workflow YAML, `uv.lock`, shebang-executed `.py` by path, the husky hook) are intentionally omitted now that the default already gives every one of them LF. +# `.gitattributes` still carries its own LF pins for the execution-sensitive classes (shell, Dockerfiles, `uv.lock`, the by-path `.py` list), now redundant with this default but kept there as git-level enforcement, since `.gitattributes` stays as-is (see GOVERNANCE.md "Line Endings"). +# This file and CI (editorconfig-checker) are what actually validate them. +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +# Markdown files +[*.md] +trim_trailing_whitespace = false + +# Xml files +[*.{xml,csproj,props,targets}] +indent_size = 2 + +# Yaml files +[*.{yml,yaml}] +indent_size = 2 + +# Windows batch and command scripts: the one CRLF exception to the `[*]` LF default above. +[*.{bat,cmd}] +end_of_line = crlf + +# .NET-only below, covering C# and ReSharper style. +# Everything above is the line-ending governance every derived repo carries, and a non-.NET repo may drop from here down. + +# C# files +[*.cs] +# Suppressions follow CODESTYLE.md "Analyzer Diagnostics and Suppressions". +# Prefer a [SuppressMessage] attribute, or the owning project's .editorconfig. +# Relax a rule repo-wide here only when it applies to every project, never for a brownfield batch. +dotnet_diagnostic.IDE0055.severity = none +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = false +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true +csharp_prefer_braces = true +csharp_prefer_simple_default_expression = true +csharp_prefer_simple_using_statement = true +csharp_prefer_static_anonymous_function = true +csharp_prefer_static_local_function = true +csharp_prefer_system_threading_lock = true +csharp_preferred_modifier_order = public,private,protected,internal,file,static,abstract,sealed,virtual,override,readonly,unsafe,volatile,async,extern,new,partial:warning +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = false +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false +csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true +csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true +csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true +csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true +csharp_style_allow_embedded_statements_on_same_line_experimental = true +csharp_style_conditional_delegate_call = true +csharp_style_deconstructed_variable_declaration = true +csharp_style_expression_bodied_accessors = true +csharp_style_expression_bodied_constructors = true +csharp_style_expression_bodied_indexers = true +csharp_style_expression_bodied_lambdas = true +csharp_style_expression_bodied_local_functions = true +csharp_style_expression_bodied_methods = true +csharp_style_expression_bodied_operators = true +csharp_style_expression_bodied_properties = true +csharp_style_implicit_object_creation_when_type_is_apparent = true +csharp_style_inlined_variable_declaration = true +csharp_style_namespace_declarations = file_scoped +csharp_style_pattern_matching_over_as_with_null_check = true +csharp_style_pattern_matching_over_is_with_cast_check = true +csharp_style_prefer_extended_property_pattern = true +csharp_style_prefer_implicitly_typed_lambda_expression = true +csharp_style_prefer_index_operator = true +csharp_style_prefer_local_over_anonymous_function = true +csharp_style_prefer_method_group_conversion = true +csharp_style_prefer_not_pattern = true +csharp_style_prefer_null_check_over_type_check = true +csharp_style_prefer_pattern_matching = true +csharp_style_prefer_primary_constructors = true +csharp_style_prefer_range_operator = true +csharp_style_prefer_readonly_struct = true +csharp_style_prefer_readonly_struct_member = true +csharp_style_prefer_switch_expression = true +csharp_style_prefer_top_level_statements = true +csharp_style_prefer_tuple_swap = true +csharp_style_prefer_unbound_generic_type_in_nameof = true +csharp_style_prefer_utf8_string_literals = true +csharp_style_throw_expression = true +csharp_style_unused_value_assignment_preference = discard_variable +csharp_style_unused_value_expression_statement_preference = discard_variable +csharp_style_var_elsewhere = false +csharp_style_var_for_built_in_types = false +csharp_style_var_when_type_is_apparent = false +csharp_using_directive_placement = outside_namespace +dotnet_code_quality_unused_parameters = all +dotnet_hide_advanced_members = false +dotnet_member_insertion_location = with_other_members_of_the_same_kind +dotnet_naming_rule.camel_case_for_private_internal_fields.severity = suggestion +dotnet_naming_rule.camel_case_for_private_internal_fields.style = camel_case_underscore_style +dotnet_naming_rule.camel_case_for_private_internal_fields.symbols = private_internal_fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style +dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields +dotnet_naming_rule.static_fields_should_have_prefix.severity = suggestion +dotnet_naming_rule.static_fields_should_have_prefix.style = static_prefix_style +dotnet_naming_rule.static_fields_should_have_prefix.symbols = static_fields +dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case +dotnet_naming_style.camel_case_underscore_style.required_prefix = _ +dotnet_naming_style.pascal_case_style.capitalization = pascal_case +dotnet_naming_style.static_prefix_style.capitalization = camel_case +dotnet_naming_style.static_prefix_style.required_prefix = s_ +dotnet_naming_symbols.constant_fields.applicable_kinds = field +dotnet_naming_symbols.constant_fields.required_modifiers = const +dotnet_naming_symbols.private_internal_fields.applicable_accessibilities = private, internal +dotnet_naming_symbols.private_internal_fields.applicable_kinds = field +dotnet_naming_symbols.static_fields.applicable_accessibilities = private, internal, private_protected +dotnet_naming_symbols.static_fields.applicable_kinds = field +dotnet_naming_symbols.static_fields.required_modifiers = static +dotnet_prefer_system_hash_code = true +dotnet_property_generation_behavior = prefer_throwing_properties +dotnet_remove_unnecessary_suppression_exclusions = none +dotnet_search_reference_assemblies = true +dotnet_separate_import_directive_groups = false +dotnet_sort_system_directives_first = true +dotnet_style_allow_multiple_blank_lines_experimental = true +dotnet_style_allow_statement_immediately_after_block_experimental = true +dotnet_style_coalesce_expression = true +dotnet_style_collection_initializer = true +dotnet_style_explicit_tuple_names = true +dotnet_style_namespace_match_folder = true +dotnet_style_null_propagation = true +dotnet_style_object_initializer = true +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity +dotnet_style_parentheses_in_other_operators = never_if_unnecessary +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity +dotnet_style_predefined_type_for_locals_parameters_members = true +dotnet_style_predefined_type_for_member_access = true +dotnet_style_prefer_auto_properties = true +dotnet_style_prefer_collection_expression = when_types_loosely_match +dotnet_style_prefer_compound_assignment = true +dotnet_style_prefer_conditional_expression_over_assignment = true +dotnet_style_prefer_conditional_expression_over_return = true +dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed +dotnet_style_prefer_inferred_anonymous_type_member_names = true +dotnet_style_prefer_inferred_tuple_names = true +dotnet_style_prefer_is_null_check_over_reference_equality_method = true +dotnet_style_prefer_simplified_boolean_expressions = true +dotnet_style_prefer_simplified_interpolation = true +dotnet_style_qualification_for_event = false +dotnet_style_qualification_for_field = false +dotnet_style_qualification_for_method = false +dotnet_style_qualification_for_property = false +dotnet_style_readonly_field = true +dotnet_style_require_accessibility_modifiers = for_non_interface_members + +# ReSharper settings +resharper_csharp_trailing_comma_in_multiline_lists = true +resharper_csharp_var_for_built_in_types = false +resharper_csharp_var_when_type_is_apparent = false +resharper_csharp_var_when_type_is_not_apparent = false diff --git a/.editorconfig-checker.json b/.editorconfig-checker.json index e019960b..22add8bf 100644 --- a/.editorconfig-checker.json +++ b/.editorconfig-checker.json @@ -1,10 +1,10 @@ -{ - "Disable": { - "Charset": true, - "Indentation": true, - "IndentSize": true, - "TrimTrailingWhitespace": true, - "InsertFinalNewline": true, - "MaxLineLength": true - } -} +{ + "Disable": { + "Charset": true, + "Indentation": true, + "IndentSize": true, + "TrimTrailingWhitespace": true, + "InsertFinalNewline": true, + "MaxLineLength": true + } +} diff --git a/.gitattributes b/.gitattributes index d420f81f..ea97e4b3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,52 +1,52 @@ -# Default: git does not normalize line endings (`* -text`), and .editorconfig end_of_line rules guide what the editor writes. -# The exception pins below are git's own enforcement, forcing LF for execution-sensitive classes regardless of editor. -# Configure with: git config --global core.autocrlf false -# Renormalize with: git add --renormalize . -# Inspect with: git ls-files --eol -* -text - -# Exception: scripts must stay LF regardless of the `* -text` default, because a CRLF shebang breaks execution. -# `.editorconfig` covers `*.sh`, but an extensionless executable matches no extension rule. -# Pin those here, so git enforces LF on checkout and on `--renormalize`. -# Any repo whose tooling ships extensionless scripts adds the matching path pin. -# The s6-overlay init is `Docker/s6-overlay/** text eol=lf`, and a husky or git hook is `.husky/pre-commit text eol=lf`. -*.sh text eol=lf -# The husky pre-commit snippet is an extensionless shebang script (like a copied .husky/pre-commit). -catalog/snippets/husky/pre-commit text eol=lf -# This repository's own hook, which is the extensionless case the comment above names. -.husky/pre-commit text eol=lf - -# Vanilla `.py` follows the CRLF default, since Python's universal newlines accept CRLF and it is commonly edited on Windows. -# Pin LF only for a `.py` executed directly via its shebang, by path. -# Those are the CI validation entry point, the fleet-audit runner, the agent-safety hook and its installer with the installer's tests, and the repo lint and review scripts with their tests. -# Do not re-add a blanket `*.py text eol=lf`. -spec/validate.py text eol=lf -spec/audit.py text eol=lf -spec/fidelity_honesty.py text eol=lf -spec/workflow_reuse.py text eol=lf -host-setup/agent-safety/gh-write-guard.py text eol=lf -host-setup/agent-safety/install.py text eol=lf -host-setup/agent-safety/test_install.py text eol=lf -scripts/prose_lint.py text eol=lf -scripts/repo_gate.py text eol=lf -scripts/pr_review.py text eol=lf -scripts/tests/test_prose_lint.py text eol=lf -scripts/tests/test_repo_gate.py text eol=lf -scripts/tests/test_pr_review.py text eol=lf -scripts/host_gate.py text eol=lf -scripts/tests/test_host_gate.py text eol=lf -scripts/tests/test_bootstrap.py text eol=lf -scripts/build_dist.py text eol=lf -scripts/tests/test_build_dist.py text eol=lf -scripts/skills_install.py text eol=lf -scripts/tests/test_skills_install.py text eol=lf - -# The uv tool regenerates uv.lock with LF on every platform, so pin it here. -# That makes git enforce LF on checkout and renormalize, so a CRLF-default repo does not fight the tool on every `uv lock` or `uv sync`. -# A repo with no lockfile is unaffected, so this pin is forward-declared. -uv.lock text eol=lf - -# Dockerfiles must be LF, because a CRLF breaks RUN heredocs and line continuations. -# A repo with no Dockerfile is unaffected, so these pins are forward-declared. -Dockerfile text eol=lf -*.Dockerfile text eol=lf +# Default: git does not normalize line endings (`* -text`), and .editorconfig end_of_line rules guide what the editor writes. +# The exception pins below are git's own enforcement, forcing LF for execution-sensitive classes regardless of editor. +# Configure with: git config --global core.autocrlf false +# Renormalize with: git add --renormalize . +# Inspect with: git ls-files --eol +* -text + +# Exception: scripts must stay LF regardless of the `* -text` default, because a CRLF shebang breaks execution. +# `.editorconfig` covers `*.sh`, but an extensionless executable matches no extension rule. +# Pin those here, so git enforces LF on checkout and on `--renormalize`. +# Any repo whose tooling ships extensionless scripts adds the matching path pin. +# The s6-overlay init is `Docker/s6-overlay/** text eol=lf`, and a husky or git hook is `.husky/pre-commit text eol=lf`. +*.sh text eol=lf +# The husky pre-commit snippet is an extensionless shebang script (like a copied .husky/pre-commit). +catalog/snippets/husky/pre-commit text eol=lf +# This repository's own hook, which is the extensionless case the comment above names. +.husky/pre-commit text eol=lf + +# Vanilla `.py` follows the CRLF default, since Python's universal newlines accept CRLF and it is commonly edited on Windows. +# Pin LF only for a `.py` executed directly via its shebang, by path. +# Those are the CI validation entry point, the fleet-audit runner, the agent-safety hook and its installer with the installer's tests, and the repo lint and review scripts with their tests. +# Do not re-add a blanket `*.py text eol=lf`. +spec/validate.py text eol=lf +spec/audit.py text eol=lf +spec/fidelity_honesty.py text eol=lf +spec/workflow_reuse.py text eol=lf +host-setup/agent-safety/gh-write-guard.py text eol=lf +host-setup/agent-safety/install.py text eol=lf +host-setup/agent-safety/test_install.py text eol=lf +scripts/prose_lint.py text eol=lf +scripts/repo_gate.py text eol=lf +scripts/pr_review.py text eol=lf +scripts/tests/test_prose_lint.py text eol=lf +scripts/tests/test_repo_gate.py text eol=lf +scripts/tests/test_pr_review.py text eol=lf +scripts/host_gate.py text eol=lf +scripts/tests/test_host_gate.py text eol=lf +scripts/tests/test_bootstrap.py text eol=lf +scripts/build_dist.py text eol=lf +scripts/tests/test_build_dist.py text eol=lf +scripts/skills_install.py text eol=lf +scripts/tests/test_skills_install.py text eol=lf + +# The uv tool regenerates uv.lock with LF on every platform, so pin it here. +# That makes git enforce LF on checkout and renormalize, so a CRLF-default repo does not fight the tool on every `uv lock` or `uv sync`. +# A repo with no lockfile is unaffected, so this pin is forward-declared. +uv.lock text eol=lf + +# Dockerfiles must be LF, because a CRLF breaks RUN heredocs and line continuations. +# A repo with no Dockerfile is unaffected, so these pins are forward-declared. +Dockerfile text eol=lf +*.Dockerfile text eol=lf diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 96cd6921..7858b273 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,363 +1,363 @@ -# Copilot Instructions - -Repository conventions for GitHub Copilot (and any other AI agent reading this file). - -The **canonical guide is [AGENTS.md](../AGENTS.md)** at the repo root. Read it first, then the [PR Review Etiquette](../GOVERNANCE.md#pr-review-etiquette) review-loop contract this file's runbook implements. This file is intentionally narrow: commit/PR-title conventions (summarized inline so VS Code's commit-message and PR-title generators have them), guidance for reviewing carried fleet content, plus the GitHub Copilot Review Runbook. - -For code-style rules, see [`CODESTYLE.md`](../CODESTYLE.md) at the repo root, one guide with a General section plus a section per language the repo uses. - -Do not duplicate language-specific rules here. **Project-specific conventions and API/behavioral contracts also belong in [GOVERNANCE.md](../GOVERNANCE.md), not here.** This file is intentionally limited to the inline commit/PR-title summary, the guidance for reviewing carried fleet content, and the GitHub Copilot Review Runbook. Non-Copilot agents (Claude Code, Codex, Cursor, ...) are not directed to this file and don't read it by default, so any rule a reviewer must honor has to live in `GOVERNANCE.md`, routed to from `AGENTS.md`, to be provider-independent. - -## Commit Messages and Pull Request Titles - -Summarized for VS Code's generators. The full rules, rationale, and examples are in [GOVERNANCE.md "Pull Request Title and Commit Message Conventions"](../GOVERNANCE.md#pull-request-title-and-commit-message-conventions). - -- Imperative subject, <= 72 characters, no trailing period, with an optional blank-line-separated body for the non-obvious *why*. -- US English, title case with lowercase short bind words. No vague titles, no `Co-Authored-By:` unless asked, no release-bump magnitude (NBGV handles versioning). Dependabot's `Bump X from Y to Z` titles are fine. -- develop PRs squash-merge (`gh pr merge --squash`), main PRs merge-commit (`--merge`). A mismatched flag is rejected by branch protection. - -## Reviewing Carried Fleet Content - -Several of this repository's governance files are carried from a shared template and kept in sync across a fleet of sibling repositories, among them `AGENTS.md`, `CODESTYLE.md`, `WORKFLOW.md`, this file, and the `repo-config/` rulesets. Most of `GOVERNANCE.md` is universal fleet law: every section that states a rule, as opposed to the two that describe this repository's own directory tree and devcontainer, is byte-locked and verified by an automated byte-for-byte match against the template canonical, not by line-by-line review. `AGENTS.md` is the thin router and carries three byte-locked sections of its own, `Fleet Bootstrap`, `Context and Delegation Discipline`, and `Where the Rules Live`. Everything above the first of those headings is this repository's own prose, saying what this repository is and which local doc owns which subject, and it is editable like any other repository content. - -Three constraints follow when reviewing that content. - -- **A reference inside byte-locked text to a path or section this repository does not carry is intentional, not a broken link.** Universal rule text names shared infrastructure (a fleet registry, a reusable config snippet, the other workflow model's ruleset payload) that a given repository legitimately may not contain. Editing the text to "fix" such a reference would break the fleet audit that governs it, so the reference is correct as written. Do not report it as a dead link, a missing file, or a broken cross-reference. -- **A genuine substantive defect is still worth raising.** Byte-locked is not unreviewable. A self-contradiction, a factual error, or a real typo in the canonical prose is a valid finding, but note that the fix lands at the template and re-vendors to every repository, rather than proposing a local edit the audit would reject. -- **A reference to a hub script is a pointer to follow, not a broken local path.** The fleet's gates live in one place and a repository runs them from a checkout of that place rather than holding a copy, so `scripts/prose_lint.py` (prose the CI linters pass on), `scripts/repo_gate.py` (repository settings and action pins), `scripts/pr_review.py` (the review digest, and reply plus resolve without a hand-typed id), and `spec/audit.py` (the conformance audit) resolve there and in none of the repositories they measure. [GOVERNANCE.md "Documentation Style Conventions"](../GOVERNANCE.md#documentation-style-conventions) carries the exception that permits such a pointer inside carried text, and [GOVERNANCE.md "Hub-Hosted Tooling"](../GOVERNANCE.md#hub-hosted-tooling) states how one is reached and what to report when it cannot be. Reach for them before writing a check of your own, since a reconstructed gate encodes its author's reading of a rule rather than the rule, and agrees with no other repository. - -## GitHub Copilot Review Runbook - -> This runbook implements the [GOVERNANCE.md "PR Review Etiquette"](../GOVERNANCE.md#pr-review-etiquette) review-loop contract for GitHub Copilot. Without it in-repo, an agent has no pointer to the reliable Copilot mechanics and falls back to known-broken paths (the no-op `POST /requested_reviewers`, the wrong bot-login filter). In the API snippets below, fill the `<owner>` / `<repo>` / `<N>` placeholders. - -Use this section for provider-specific mechanics. The expected review loop *contract* (request review on every push, verify head-SHA coverage, triage findings, reply + resolve, escalate when stuck) is defined in [GOVERNANCE.md -> PR Review Etiquette](../GOVERNANCE.md#pr-review-etiquette). This section only describes how to make GitHub Copilot reliably execute it. - -### Triggering and Polling - -Auto-review on push is configured (via the branch ruleset's `copilot_code_review` rule with `review_on_push: true`) but fires inconsistently in practice, so treat it as best-effort, not guaranteed. After every push, **re-request a review programmatically** via the GraphQL `requestReviews` mutation, passing the Copilot reviewer's bot node id in `botIds`. This drives the loop end-to-end without a UI hand-off. - -**A review with no inline comments is still a completed review, not a failure, and not a reason to ask the maintainer to re-trigger.** Copilot very often posts a single formal review (GraphQL `state: COMMENTED`) whose body ends with "...reviewed N of N changed files ... and generated no comments" and adds **zero** inline threads. That review carries the head `commit.oid` and fully satisfies the loop, and it is the clean-pass success case. Never read "no inline comments" as "the review didn't run," and never re-request or escalate to the maintainer because comments are absent. - -**The one exception is a review that says it did not review, and it is delivered in exactly that shape.** Copilot answers a pull request it will not take on with a formal review, `state: COMMENTED`, carrying the correct `commit.oid` and **zero** inline threads, whose whole body is a refusal: "Copilot wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again." Every coverage check passes, the rule above says an empty review is the clean pass, and the two together read a round that never happened as a round that found nothing. Observed on a pull request of 301 changed files, one over the limit, which was one command from merging on it. **The limit is 300 changed files and the remedy is to split the pull request**, since re-requesting the same head repeats the refusal: the file count is what it declined on and re-requesting does not change it. A repository committing binary or generated data alongside code crosses that line easily. Match the refusal on the body's **opening line** rather than anywhere in it, because a review discussing the wording is not one carrying it, and one line rather than two, because a review's first line is its heading and its second is the overview prose where such a description sits. Match an alternation for the same reason the suppressed heading takes one: - -```sh -# A review whose opening line declines the round. That line is the unit, since a refusal is -# the whole body and a match further down is a review quoting the wording rather than refusing. -# The dot spans both spellings of the apostrophe, the typographic one Copilot writes and the -# ASCII one, and it also keeps this filter usable inside single quotes, which neither survives. -gh api repos/<owner>/<repo>/pulls/<N>/reviews --jq \ - '.[] | select([(.body // "") | split("\n")[] | select(. != "")][0] // "" - | test("wasn.t able to review|was not able to review|unable to review")) | {commit_id, body}' -``` - -**Read the low-confidence findings, which are not inline threads.** A review body can carry a collapsed `<details>` block of findings Copilot withheld from the inline threads, and those findings appear nowhere in `reviewThreads`, so a loop that polls threads alone never sees them and reports a clean pass. **Match the block on more than one phrasing.** Its heading has appeared both as `Suppressed comments (N)` and as "Comments suppressed due to low confidence", so a filter keyed on either one alone silently reports zero suppressed findings on a review that has them, the same false clean this rule exists to prevent, one level up in the detection. **The section moves as well as it is worded, so match the heading wherever it sits.** It has appeared as its own `<details>` wrapper with a matching `<summary>`, as a bare heading in the body, and as a Markdown heading nested inside the `Review details` wrapper, whose `<summary>` names the wrapper and not the section. A filter reading a wrapper's `<summary>` reports zero on the nested shape, and the count it needs is the heading's own `(N)` rather than the wrapper's. They have been right repeatedly, including a rule stated more broadly than its check enforced and a check that skipped fenced blocks in every rule but one. Read the body of every review, investigate each suppressed finding on the same footing as an inline one, and answer it in the PR conversation, since a suppressed finding has no thread to reply on or resolve. - -```sh -# `test` with an alternation, not `contains` on one phrasing: the heading wording has changed. -gh api repos/<owner>/<repo>/pulls/<N>/reviews --jq \ - '.[] | select(.body | test("Suppressed comments|low confidence")) | .body' - -# Read every round, not only the head. A suppressed finding has no resolved state, so a push -# does not retire it: it simply stops appearing in a head-scoped query while still unanswered. -# Head-scoping this query is how four rounds went unanswered across three pull requests in a day. -gh api repos/<owner>/<repo>/pulls/<N>/reviews --jq \ - '[.[] | select(.body | test("Suppressed comments|low confidence"))] | length' - -# Mark which round each came from, since a finding on an older round may since be moot. -PR_HEAD=$(gh pr view <N> --json headRefOid --jq '.headRefOid') -gh api repos/<owner>/<repo>/pulls/<N>/reviews --jq \ - "[.[] | select(.body | test(\"Suppressed comments|low confidence\")) - | {round: (if .commit_id == \"$PR_HEAD\" then \"head\" else \"earlier\" end), id}]" -``` - -**Round 1 is normally auto-seeded, so poll for it before trying to self-trigger.** Auto-review-on-open supplies the first review with no `botIds` call needed, but it can lag one to three minutes, and on some pull requests it never fires at all. After opening a PR (or the first push), **poll** for a Copilot review on the head SHA (see [Verify Review Covered Current Head](#verify-review-covered-current-head)) before concluding none ran. Where it never lands, drive round 1 with the same `requestReviews` mutation every later round uses, which needs nothing this PR has to produce first. A round 1 carrying no review therefore means "wait, then request it yourself," **not** "ask the maintainer to kick it off." - -> **The reviewer login differs by API, in three forms rather than two.** In **GraphQL** (`gh api graphql` and `gh pr view --json reviews`, which is GraphQL-backed) the `Bot.login` is `copilot-pull-request-reviewer`, with **no `[bot]` suffix**. In the **REST** API (`gh api repos/.../issues|pulls/...`) the same account's `user.login` is `copilot-pull-request-reviewer[bot]`, **with** the suffix. In a REST **timeline** `review_requested` event the `requested_reviewer` is a third spelling again, login `Copilot` with `type` `Bot`, so a filter written against either of the other two selects nothing there and reports a pull request with requests as having none. Match on the type plus a loose login test rather than on any one spelling, and each query below uses the correct form for its API. - -```sh -# 1. PR node id, plus the reviewer bot's node id read across the repo's recent PRs. -# The bot id is the reviewer account's own, so every PR in the repo carries the same one. -# The reviewer login is `copilot-pull-request-reviewer` in GraphQL. -PR_NODE=$(gh pr view <N> --json id --jq '.id') -BOT_ID=$(gh api graphql -f query=' -{ - repository(owner: "<owner>", name: "<repo>") { - pullRequests(first: 20, orderBy: { field: CREATED_AT, direction: DESC }) { - nodes { reviews(first: 20) { nodes { author { __typename login ... on Bot { id } } } } } - } - } -}' --jq '[.data.repository.pullRequests.nodes[].reviews.nodes[] - | select(.author.login == "copilot-pull-request-reviewer") - | .author.id] | first // empty') -if [ -z "$BOT_ID" ]; then - echo "no Copilot review in the 20 most recent PRs, so widen the window" >&2 - return 1 2>/dev/null || exit 1 # Stop. Do NOT call requestReviews with an empty id. -fi - -# 2. Re-request a Copilot review on the current head. -gh api graphql -f query=' -mutation($pr: ID!, $bot: ID!) { - requestReviews(input: { pullRequestId: $pr, botIds: [$bot], union: true }) { - pullRequest { id } - } -}' -F pr="$PR_NODE" -F bot="$BOT_ID" -``` - -**The bot node id belongs to the reviewer account, not to a pull request**, and it is the same id on **every PR in the repo**, so nothing has to land on this PR before step 1 can read it. A PR opened a minute ago, with no review and no comment of its own, needs no UI seeding to bootstrap the id and no prior review to source it from: any Copilot review anywhere in the repo carries it. Query the **most recent** PRs, since a plain `last: 20` returns the *oldest* ones, which may predate Copilot on the repo. **Guard for an empty result**, because an empty `$BOT_ID` says only that none of the PRs sampled carry a Copilot review, so widen the window (raise the count or paginate) before concluding the repo has never had one. Never pass an empty id to the mutation. - -A read scoped to this PR (`pullRequest(number: <N>) { reviews }`) returns the same id once a review has landed here, and it buys nothing over the repo-wide read while failing on exactly the round the repo-wide read handles. Where the repo's only Copilot artifact is an issue comment rather than a formal review, read the id from that comment's author instead (`pullRequest.comments` -> author `... on Bot { id }`). Manual UI seeding is the last resort, needed only for a repo that has **never** had a Copilot review, so no prior id exists anywhere to read. - -**Do NOT post `@Copilot review` as a PR comment.** That comment triggers the Copilot *coding agent* (`copilot-swe-agent[bot]`), which makes code changes rather than posting a review. - -Known non-working request paths (don't rely on them, and use the `requestReviews` mutation above instead): - -- `POST /requested_reviewers` with `reviewers=[Copilot]` can return 200 but no-op. -- `copilot-pull-request-reviewer` as a requested reviewer slug returns 422. -- `requestReviews` with the reviewer's bot node id in **`userIds`** fails with `Could not resolve to User node`, because the Copilot reviewer is a **Bot**, so its node id goes in **`botIds`** (as in the mutation above), never `userIds`. -- `suggestedActors(capabilities: [CAN_BE_ASSIGNED])` lists `copilot-swe-agent` (the coding agent), not `copilot-pull-request-reviewer`, so do not source the reviewer's bot node id there. Read it from an existing review per step 1 above. -- There is no `removePullRequestFromReviewRequest` mutation, but removal is not therefore impossible: `requestReviews` **replaces** the reviewer set when `union` is false (the schema describes `union` as "add users to the set rather than replace"), so an empty `botIds` with `union: false` removes the pending request. Reach for it only in the stuck case below, since `union: true` re-fires a review on the current head without it. -- `gh pr view --json reviewRequests` **omits a Bot reviewer entirely**, reporting an empty set while Copilot sits in it. Read the pending set through GraphQL `reviewRequests`, which returns the `Bot` node, because the REST-backed projection makes a pending request read as no request at all. - -### Verify Review Covered Current Head - -Before merging, confirm Copilot reviewed the current PR head SHA. Copilot may respond as either a formal review (carries an exact commit SHA) or an issue comment (no SHA, so use the most recent Copilot comment for manual confirmation). Check both. - -**Count matches and compare numerically, so an empty result cannot read as success.** A poll that captures a `gh api --jq` result and exits on `[ "$found" != "0" ]` treats an **empty** string as a landed review, and an empty string is exactly what a mis-written filter returns. Pipe the matches through `wc -l` and test `-gt 0`, so a query that finds nothing and a query that ran wrong both read as "not yet". A `gh` call that fails to run reaches the test the same way, because it writes its message to stderr and prints nothing to stdout, so the `$(...)` around it still yields the empty string. A mistyped or unsupported flag is the usual cause, and `gh` reports one as `accepts 1 arg(s), received 4` rather than as anything resembling a review verdict. - -**Check head coverage before reading merge-state, never the reverse.** A push makes the required checks go green before Copilot re-reviews the new head, so `mergeStateStatus` can read `CLEAN` in the window before any formal review covers the head. A poll that exits on `CLEAN` merges into that gap. Gate on a formal review whose `commit.oid` equals the current head SHA first, then on zero unresolved threads, and only then read merge-state. - -```sh -PR_HEAD=$(gh pr view <N> --json headRefOid --jq '.headRefOid') - -# 1. Formal review - exact SHA match. -gh pr view <N> --json reviews --jq \ - '.reviews[] | select(.author.login=="copilot-pull-request-reviewer") | .commit.oid' \ - | grep -q "$PR_HEAD" && echo "covered via formal review" - -# 2. Issue comment - show the most recent Copilot comment for manual -# confirmation. This is the REST API, so the login carries the `[bot]` suffix. -gh api repos/<owner>/<repo>/issues/<N>/comments --jq \ - '[.[] | select(.user.login=="copilot-pull-request-reviewer[bot]")] | last | {created_at, body: .body[:200]}' -``` - -Coverage is confirmed when (1) exits 0, and **a formal review with no inline comments still satisfies path (1)**, because coverage is about the head SHA, not the comment count. The exception is the refusal above, which is a formal review on the head with no inline comments and covers nothing, so path (1) exits 0 over a round that never ran. Read the body of the review the SHA matched, not only the SHA. For issue comments (path 2), body content is the only reliable signal, and `created_at` is not: `git log -1 --format=%cI` is the **commit** timestamp, not the push timestamp, so amended or rebased commits can have an earlier timestamp and an older Copilot comment could satisfy a time check even though Copilot never saw the current head. Treat path (2) as confirmed only when the comment body explicitly refers to the current changes. - -**Coverage of the head is not coverage of the diff, and the second one is stated in a line nothing above reads.** A review body says how many of the pull request's changed files it read, and a round that read fewer than the pull request changed is byte for byte the clean pass in everything else: the same `commit.oid`, the same absent threads, the same "generated no comments". Measured over 332 Copilot review bodies on this repository, five rounds across three pull requests reported reading fewer files than were changed, and all three merged. One of them changed three files, left one unread across **both** its rounds, and reported no comments each time. This is the third instance of the shape the refusal above and the suppressed block below are the first two, so read it the same way: **fail closed on a wording you do not recognize**, since a gate that allows whatever it does not recognize stops gating as the wording drifts, and both of those wordings have drifted once already. - -Two spellings carry the count, and both are current rather than one superseding the other. Each opens its own line, which is what separates the round stating its coverage from prose mentioning changed files, that prose being what a review of a change to this rule looks like: - -```text -Copilot reviewed 2 out of 3 changed files in this pull request and generated no comments. -- **Files reviewed:** 2/3 changed files -``` - -The sentence tail after the first spelling reports how many comments the round raised and appears in four wordings. It is not coverage, so it is not part of what has to be recognized, and the counts are. Read them into three verdicts and two exemptions: - -- **Counts equal** - the round read the whole diff. This is the clean pass. -- **Counts unequal** - files in the diff have no review at all. Do **not** treat a re-request as the remedy: measured over four pull requests and seven rounds on this repository, every partial round stayed partial at the identical ratio and no round ever recovered, so re-requesting spends a round and changes nothing. Splitting works where it applies and does not apply to a promotion, whose head is `develop`. **The file table in the body does not tell you which file went unread**, and it looks as though it should, which is why it is written down here: measured over 348 review bodies on this repository and 121 on another in this fleet, that table names the whole changed set on partial and fully covered rounds alike, so a table naming every changed file is what a full round carries too and contradicts nothing. One round of the seven is the exception, stating 16 of 17 and naming 16, omitting `GOVERNANCE.md`, and `status` names an omitted file only in that shape, where the table is short by exactly what the counts leave unread and names nothing outside the diff. Treat that as a lead to check rather than a verdict, one round here naming `GOVENANCE.md`, the reviewer's own spelling and a path no diff carries. Report the state and hand the merge decision to the maintainer. -- **Coverage-shaped and unreadable** - the remedy is to fix the reader, not to read past it. The vetted spellings live in `scripts/pr_review.py` and here, and they stay in step because a case reads them out of this file. -- **Exempt: a body stating no coverage at all.** 28 of those 332 bodies are an overview and a change list and nothing more. That shape is current, interleaves with the counted one throughout, and one pull request carries both across its two rounds, so treating it as a failure cries wolf on about one review in twelve and teaches an agent to work around the gate. It reads as `coverage=unstated`, never as a pass and never as a failure. -- **Exempt: a refusal.** It carries no coverage line by design, and the refusal rule above has already classified it. Read it here as well and every refusal grows a spurious second failure on top of the one that names its remedy. - -`scripts/pr_review.py status <N> --repo <owner>/<repo>` reports this as `coverage=full`, `coverage=PARTIAL`, `coverage=UNVETTED` or `coverage=unstated`, and exits `42` on a partial round. An unreadable wording exits `43` instead, as one of the unrecognized shapes below rather than as a case of its own, since both say the reader is what needs fixing. Read it by hand as: - -```sh -gh pr view <N> --json reviews --jq \ - '.reviews[] | select(.author.login=="copilot-pull-request-reviewer") | .body - | split("\n")[] | select(test("^(Copilot|[-*] \\*\\*Files reviewed:).*changed files?"))' -``` - -### A Shape Nothing Recognizes Blocks the Loop and Earns an Issue - -**Every rule above keys on a marker in what Copilot sent, so a marker that changes spelling is a section the reader stops finding and reports as absent.** That is not a hypothetical: all three failures on record here have exactly that shape. The suppressed heading was reworded and the count went to zero. The suppressed section moved inside another wrapper and the count went to zero again. The coverage line was never read at all. Each one reported a clean pass over a review it had misread, and each was caught by the maintainer after it had already landed, rather than by the gate. - -**So an unrecognized shape is a blocking outcome, and its remedy is an issue rather than a judgment call.** When any reader here meets a heading, a collapsed section, a metadata line, a coverage wording or a reviewer login it has no vetted spelling for, the review loop **does not close**, whatever else the digest says. Do not read past it, do not infer what the new wording probably means, and do not treat a body that looks clean as a clean review, because "looks clean" is precisely what a misread review looks like. Two things follow, in this order: - -1. **File an issue on the hub**, the repository `AGENTS.md` "Fleet Bootstrap" names, which hosts `scripts/pr_review.py` and holds the vetted inventory. Name each unrecognized shape and quote the review body it came from, so the fix is made against the real wording rather than a paraphrase. The issue is filed even when the shape turns out to be cosmetic, since "cosmetic" is a conclusion drawn after reading the body and not before. -2. **The merge decision is the maintainer's**, not the agent's and not the script's. An unrecognized shape does not mean the pull request is bad, it means nothing here can vouch for the review of it. Report the state, hand it over, and stop. - -`scripts/pr_review.py status <N> --repo <owner>/<repo>` reports this as `shapes=UNRECOGNIZED`, lists each shape under a marker naming the remedy, and exits `43`. `wait` carries the same code, so a wait cannot end on a clean zero over output nothing read. The vetted inventory lives in that script and is small on purpose: measured over 332 Copilot review bodies on this repository, with fenced blocks dropped and text reduced to ASCII, the whole corpus is seven headings, six `<summary>` texts and three metadata labels, and every body carries at least one of them. A body carrying none is itself the unrecognized shape, which is what catches a rewrite that changes everything at once, the refusal wording drifting among it. - -### Bounded Retry Workflow - -This path is only for a **genuinely missing** review, meaning no Copilot review (formal *or* issue comment) covers the current head SHA after polling. A review that covered the head but produced no comments is a clean pass, not a missing review, so do not enter this retry path for it. - -**A slow review is pending, not missing, so poll with backoff and never escalate on a timeout alone.** Copilot can lag far beyond the usual one-to-three minutes when it has been re-requested many times in quick succession, because it throttles under load, and a re-review landing tens of minutes after the request is normal. A poll that times out is therefore evidence only that the review has not landed *yet*, not that Copilot is done or unresponsive. Report the status as "review still pending" and keep polling on a widening interval (for example 20s steps, then a few minutes) rather than stopping. Enter the escalation step below only when the `requestReviews` mutation itself no-ops or errors, or after a genuinely long wait with the request confirmed accepted, never merely because one fixed poll window elapsed. - -**Bound each wait, and read what Copilot actually posted before opening another one.** A poll that widens forever is indistinguishable from a poll that has stopped, and "still pending" is the honest report for exactly as long as evidence supports it. Two readings decide whether waiting again is warranted. Compare the request's timestamp against the newest Copilot activity of **any** kind on the pull request, since a reviewer that has already answered on a later head, or that posted an issue comment instead of a formal review, is not a reviewer running late, and a wait that keeps reporting "pending" against a landed review is a broken wait rather than a slow reviewer. Then read that newest response, because a Copilot answer naming a quota or a rate limit is a **terminal** outcome rather than a pending one: no formal review will land, so path (1) never matches the head and path (2) is correctly never confirmed, both paths behave exactly as specified, and the agent waits for something that is not coming. The fix is account-side and re-requesting does not change it, so report it to the maintainer and stop waiting. Where the newest response is neither a review nor a refusal you recognize, that too goes to the maintainer with its text, rather than being waited through. - -**A pending request nothing picked up is a third state, and it is the one that looks most like patience.** Copilot raises a `copilot_work_started` timeline event within about half a minute of accepting a request, and submits its review a few minutes later. A request that never draws one is not a slow review, it is a request nothing is acting on, and it stays that way indefinitely: one sat for thirteen and a half hours while the pull request read as waiting on the reviewer. Elapsed time cannot tell the two apart, since a genuinely slow round also shows no review, so read the event rather than the clock. `copilot_work_started` appears in the REST timeline only, and no GraphQL timeline item carries it: - -```sh -# The pending set (GraphQL, since the `gh pr view` projection cannot see a Bot reviewer). -gh api graphql -f query=' -{ repository(owner:"<owner>",name:"<repo>"){ pullRequest(number:<N>){ - reviewRequests(first:10){ totalCount - nodes{ requestedReviewer{ __typename ... on Bot{login} ... on User{login} } } } } } }' - -# The request and pickup events, newest last. A `review_requested` with no later -# `copilot_work_started` is the stuck state. Requests are filtered to the reviewer's own, -# since a human requested afterwards is a different request and reading it as this one -# reports a picked-up review as never picked up. `per_page` is the pagination cost. -gh api --paginate 'repos/<owner>/<repo>/issues/<N>/timeline?per_page=100' \ - --jq '.[] | select(.event == "copilot_work_started" or (.event == "review_requested" - and .requested_reviewer.type == "Bot" - and ((.requested_reviewer.login // "") | ascii_downcase | test("copilot")))) - | "\(.event) \(.created_at)"' -``` - -**Recover it by clearing the request and requesting again**, because the pull request UI offers no re-request control while a request is pending, and `requestReviews` with `union: true` adds a reviewer already in the set, which changes nothing. Read the pending set first, since `union: false` replaces the whole set and would drop a human reviewer requested alongside the bot. Where the clear-and-request does not draw a `copilot_work_started` within a minute or so, push a commit instead, since a new head raises a fresh request rather than poking a stale one. - -```sh -PR_NODE=$(gh pr view <N> --json id --jq '.id') -# 1. Clear. `union: false` replaces the set, so an empty botIds removes the pending request. -gh api graphql -f query=' -mutation($pr: ID!) { - requestReviews(input: { pullRequestId: $pr, botIds: [], union: false }) { - pullRequest { reviewRequests(first: 10) { totalCount } } } -}' -F pr="$PR_NODE" -# 2. Request again, against a now-empty set, with $BOT_ID read as in "Triggering and Polling". -gh api graphql -f query=' -mutation($pr: ID!, $bot: ID!) { - requestReviews(input: { pullRequestId: $pr, botIds: [$bot], union: true }) { - pullRequest { reviewRequests(first: 10) { totalCount } } } -}' -F pr="$PR_NODE" -F bot="$BOT_ID" -``` - -If a review did not run on the current head, retry: - -1. Wait briefly and check head-SHA coverage (see above). -1. Re-request the review via the `requestReviews` mutation (see "Triggering and Polling"), falling back to the GitHub PR UI only if the mutation no-ops. -1. Retry up to two more times (three total). -1. If still missing, mark review as blocked and escalate to the user/maintainer with what was attempted. - -### Reply and Thread Resolution Workflow - -Every id below is captured from a live query into a variable and passed from there, never hand-typed, guessed, or pasted as a `PRRT_...` literal. A node id resolves globally, so a fabricated or stale id does not fail, it writes to a real thread on an unrelated repository. This runbook implements [GOVERNANCE.md "Repository Boundaries and Write Safety"](../GOVERNANCE.md#repository-boundaries-and-write-safety): write only to this repo, capture every id from a live query, and never suppress a mutation's output. - -**Use the hub's helper, which has nowhere to type an id.** `scripts/pr_review.py reply <N> --repo <owner>/<name> --match "<words from the finding>" --body "<answer>" --resolve` queries the thread id itself and passes it straight to the mutation. That rule is known and read by the agents that break it anyway, three times so far, so the shape is what changes rather than the wording. It selects on the finding's own words rather than a line number, since a fix push moves the line; it refuses on no match and on more than one rather than picking; and it does not resolve a thread whose reply came back without a `url`. Cross-owner targets it refuses outright, which is where the hand-run form below applies, and there the `gh-write-guard` hook is what reads the maintainer's grant. It is hub-hosted per [GOVERNANCE.md "Hub-Hosted Tooling"](../GOVERNANCE.md#hub-hosted-tooling), so it is invoked from a hub checkout and never rebuilt locally. - -The hand-run form is below, for a cross-owner target and for the case where the hub cannot be reached and the work cannot wait. - -List unresolved threads. Use `first: 100` with cursor-based pagination, and where `hasNextPage` is true, re-run with `after: "<endCursor>"` to retrieve the next page: - -```sh -gh api graphql -f query=' -{ - repository(owner: "<owner>", name: "<repo>") { - pullRequest(number: <N>) { - reviewThreads(first: 100) { - nodes { - id isResolved path - comments(first: 1) { nodes { author { login } body } } - } - pageInfo { hasNextPage endCursor } - } - } - } -}' | jq ' - .data.repository.pullRequest.reviewThreads | - (.pageInfo | "hasNextPage=\(.hasNextPage) endCursor=\(.endCursor)"), - (.nodes[] | select(.isResolved == false)) -' -``` - -Reply on a thread, then resolve it. Capture the target thread's id into `$TID` from the listing query above, filtering to the thread being answered by its `path`, and guard for an empty result so a mutation never runs on a guessed id. When a file carries more than one unresolved thread, `path` alone is ambiguous and `head -n 1` would pick the wrong one, so narrow by first-comment body (the query already fetches `comments(first: 1)` for this) by adding `and (.comments.nodes[0].body | contains("<SNIPPET>"))` to the `select`: - -```sh -TID=$(gh api graphql -f query=' -{ - repository(owner: "<owner>", name: "<repo>") { - pullRequest(number: <N>) { - reviewThreads(first: 100) { - nodes { id isResolved path comments(first: 1) { nodes { body } } } - } - } - } -}' --jq '.data.repository.pullRequest.reviewThreads.nodes[] - | select(.isResolved == false and .path == "<PATH>") - | .id' | head -n 1) -[ -n "$TID" ] || { echo "no matching unresolved thread on <PATH> - do not guess an id" >&2; return 1 2>/dev/null || exit 1; } - -# Show the mutation's output. Never append an output-discard or force-success tail -# (>/dev/null, 2>/dev/null, &>/dev/null, || true, || :, || echo) to a write. -gh api graphql -f query=' -mutation($threadId: ID!, $body: String!) { - addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $threadId, body: $body }) { - comment { id url } - } -}' -F threadId="$TID" -F body="Fixed in <SHA>: <one-line summary>." - -# Confirm isResolved: true in this response before treating the thread as closed - a write that -# appears to fail may have taken on the server. -gh api graphql -f query=' -mutation($threadId: ID!) { - resolveReviewThread(input: { threadId: $threadId }) { thread { id isResolved } } -}' -F threadId="$TID" -``` - -Issue-level Copilot comments (those in `issues/<N>/comments`) have no resolution action, since GitHub provides no API or UI to resolve them. Reply if the finding warrants it, but no resolution step is needed or possible. - -### PR Edits and Merge-State Gotchas - -- **`gh pr edit --title/--body` is broken on `gh` 2.45.x and 2.46.x, and works from 2.47 up.** Those releases touch the deprecated Projects-classic `projectCards` GraphQL field and **exit non-zero without applying the change** (a stale PR description then survives review rounds), which the GitHub CLI maintainers name as broken by deprecated APIs. A distribution package is where that version comes from, so check `gh --version` before concluding the command is unusable, and install from the official repository rather than working around it. Where a host is genuinely stuck on one, edit via the API and verify it took: GraphQL `updatePullRequest(input: { pullRequestId, title, body })`, or REST `gh api -X PATCH repos/<owner>/<repo>/pulls/<N> -F body=@body.md` (the `@` reads the body from a file, so name it explicitly, not the literal `file`). The same version range carries no `--json` flag on `gh pr checks`, so a watcher built on it prints nothing and a quiet result reads as a passing one. -- **`main`/`develop` use rulesets, not classic branch protection.** The classic protection REST endpoint (`repos/.../branches/<b>/protection`) 404s, so read the ruleset instead. A `mergeStateStatus` of `BLOCKED` on a green PR is most often just **unresolved review threads** (the ruleset requires thread resolution), and resolving them moves it to `CLEAN`. (`BLOCKED` is a `mergeStateStatus` value, so don't confuse it with the separate `mergeable` field's `MERGEABLE`/`CONFLICTING`, which reports merge conflicts, not review gates.) -- **`BLOCKED` never says which gate, so never infer one.** The same word covers a red check, a required check nothing is running, an unresolved thread, and a missing approval, and the bullet above says "most often" rather than "always" for that reason. Read the checks instead of guessing: `pr_review.py status` prints `checks=N/M` beside the merge word and names a stuck one, and it exits `44` from `wait` where the merge reads `BLOCKED`, the review loop closed, and a check is starved, expected and never posted, running far past what the job costs, or failed. A **queued check with no runner** is the case that reads exactly like patience: a run here polled `BLOCKED` for twenty-five minutes on a pull request whose only unfinished check was an aggregator job GitHub dispatched and never assigned a runner, and the cause came from the maintainer rather than from any field. Nothing agent-side starts that job, because the pool is GitHub-hosted, so the remedy is a re-run of the workflow or waiting on that capacity, and it is **not** a re-request, a rebase, or an empty commit. A job held behind a `needs:` dependency does not enter the rollup until that dependency finishes, so a queued check is never a dependency waiting its turn. -- **Push -> head-SHA read race.** A `headRefOid` read taken immediately after a push can return the **old** head, so re-read after the push registers, or a coverage poll evaluates the stale SHA. -- **Copilot is sometimes factually wrong** (e.g. it claimed `actionlint -color` "requires a value" when it is a boolean flag). Verify a finding before fixing, and decline with evidence when it is wrong, which is distinct from dismissing a still-present finding as stale. The evidence goes under [Disproved Claims](#disproved-claims) as well as in the thread, because the thread closes with the pull request and the next round starts without it. - -Reply-body conventions: - -- Accepted bug/style fix: include fixing commit SHA and a one-line summary. -- Declined style comment: cite the rule (GOVERNANCE.md or the CODESTYLE.md language section) and the existing-tree precedent. -- Declined architecture proposal: one-sentence rationale. -- Declined false positive on carried fleet content (a broken-link or dead-cross-reference flag inside byte-locked rule text): cite the "Reviewing Carried Fleet Content" section, since the reference is intentional and the text cannot be edited locally. - -After the final push, sweep-resolve stale older threads for removed code paths. - -### Disproved Claims - -**A disproof is proof about this repository, and the thread it was written in is not where the next round looks.** [GOVERNANCE.md "PR Review Etiquette"](../GOVERNANCE.md#pr-review-etiquette), which routes to the `pr-review-conduct` Skill, closes a false finding by disproving it in the thread, addressed to the reviewer so it does not raise the same thing again, and while the pull request is open that is the right place for it. Afterwards it is the wrong one. The pull request merges, the next round begins with no memory of the last, and the second occurrence reaches a maintainer with no way to tell it from a first. Each entry below is a claim that was tested against this repository and found false, kept so the proof is read rather than built twice. - -**An entry names the claim, what was run or read to disprove it, the revision it was proved against, and what ends it.** A disproof is true of one tree at one revision, so an entry whose subject moves is deleted by the change that moves it rather than edited to look current, which is the same sweep the [GOVERNANCE.md "Documentation Style Conventions"](../GOVERNANCE.md#documentation-style-conventions) rule already requires of prose asserting a behavior that has changed underneath it. This is deliberately not a list to append to, since an entry outliving the code it was proved against becomes a reason not to check, and that is strictly worse than proving the claim a second time. - -**The record answers a repeated claim and never dismisses a new one.** An entry is cited only where the revision it names is still what the tree carries, and the reply carries the proof re-read rather than a pointer to the entry, since a reviewer that cannot open this file learns nothing from being pointed at it. Judge a finding on its merits first and match it against this record second, because reading it the other way round is how a real finding gets closed by a stale proof. - -**The entries are this repository's own.** Each names a file and a revision, so a repository holding a copy of this file carries the shape and the rules above rather than these findings, deletes an entry whose subject it does not carry, and records what it has proved itself. - -- **`keys_unsorted` requires jq 1.6, so the ruleset normalizer in `repo-config/configure.sh` fails to compile on jq 1.5.** Raised as a suppressed finding, by analogy to the `walk/1` call the same filter was rewritten to avoid. - - **Disproved by** - running both builtins on `jq-1.5-1-a5b5cbe`, the build that reproduces the `walk/1` failure. `keys_unsorted` evaluates there and the whole normalizer returns the sorted document, while `walk(.)` on that binary answers `jq: 1 compile error`. The two builtins are not in the same position, and the analogy is the whole of what carried the finding. - - **Proved against** - the `norm` filter in `repo-config/configure.sh` on `develop` at `756a53e`. - - **Delete when** - the filter stops calling `keys_unsorted`, or nothing this check runs on carries a jq older than 1.6. - -- **Splitting the fallback parse in `host-setup/agent-safety/gh-write-guard.py` a line at a time mis-reads a newline inside a quoted argument, reintroducing the false deny that path exists to remove.** Raised against the branch that made a newline end a command, on the ground that a `--body` argument holding a newline and a `git push origin develop` would have that line read as a push. - - **Disproved by** - the arm being unreachable, and then by measuring it rather than resting on that. `punctuation_chars` arrived in Python 3.6, the module uses f-strings throughout, and `install.py` refuses to install below 3.7, so an interpreter that would raise the `TypeError` fails to import the module before reaching the fallback. Simulated against a `shlex` that rejects the keyword and passes everything else through, the quoted-newline example is allowed on both paths, because splitting a line whose quoting cannot be parsed leaves the quote glued to the token and the push target reads as `develop"`, matching no branch. The shape does bite one line further out, where a three-line body whose middle line is a bare `git push origin develop` denies on the forced path, and the alternative is worse where it counts: parsing the whole command at once keeps a quoted newline intact and drops every real one, so an ordinary push followed by a `gh pr create` denies under every interpreter rather than under none. - - **Proved against** - `_git_subcommand_arglists` in `host-setup/agent-safety/gh-write-guard.py` and the interpreter floor in `host-setup/agent-safety/install.py`, on `develop` at `dbd1cdc`. - - **Delete when** - the floor drops below 3.6, or the fallback stops splitting the command a line at a time. - - **Earned anyway** - a test case rather than a change. Only `ValueError` from unbalanced quoting reaches that path in practice and nothing covered it, so a finding wrong about its own reachability was right that the path was untested. - -- **A description's stale commit claims are found by extracting the bare SHAs it quotes.** Not a reviewer's finding but the method this repository's own backlog specified for the `claims` check in `scripts/pr_review.py`, recorded here because a rejected method costs the same to re-propose as a declined finding costs to re-derive, and because a backlog has a place for a claim the tree contradicts and none for a method a measurement rejects. - - **Disproved by** - running it over the 25 most recent merged pull requests, where it raised four references and all four were correct prose: a `develop` commit named as history, a SHA inside a pasted digest, and two commits in another repository written without a URL. Nothing in the shape of a bare SHA separates those from a claim, and separating them by meaning is the similarity heuristic [`spec/section-model.md`](../spec/section-model.md) rules out. A path arm measured on the same corpus is worse, flagging 54 of 215 backticked candidates, nearly all of them bare basenames and other repositories. - - **Proved against** - the 25 most recent merged pull requests as of `develop` at `756a53e`, the corpus on which the anchored verb form that ships instead raises one reference, and that one true. - - **Delete when** - `claims` stops reading a description for commit references. - -- **The GraphQL `pullRequests` connection defaults to `states: [OPEN]`, so the bot node id query in "Triggering and Polling" returns nothing in a repository whose Copilot-reviewed pull requests have all merged.** Raised against the repo-wide read, on the ground that a cold start is exactly the case where no open pull request carries a review. - - **Disproved by** - running the connection both ways against this repository while exactly one pull request was open. With `states` omitted, `pullRequests(first: 5, orderBy: { field: CREATED_AT, direction: DESC })` answers `628 OPEN`, `627 MERGED`, `626 MERGED`, `625 MERGED` and `624 MERGED`, so the omitted default is every state rather than `OPEN`. The same call with `states: [OPEN]` answers `628 OPEN` alone, which is the behavior the finding predicts for the first form and is what distinguishes them. The read was first run when this repository had no open pull request at all, and it returned the id from merged ones. - - **Proved against** - the `BOT_ID` query in "Triggering and Polling" in this file, run against this repository's pull request list on 2026-08-08. - - **Delete when** - that query names `states` explicitly, or stops reading pull requests to find the id. - -- **"The agent check branches" in `STANDUP.md` section 0 is a subject-verb disagreement, and should read "The agent checks branches".** Raised as a suppressed finding against a line the change under review only touched as diff context. - - **Disproved by** - reading the sentence against the snippet it describes. The subject is the noun phrase "the agent check", meaning the check for the signing agent, and "branches" is its verb, which is what the `if [ ... = ssh ]; then ssh-add -L; else gpg --list-secret-keys; fi` line does. The proposed reading needs "branches" as a plural noun, and the paragraph is section 0, before a repository exists, where the alternatives it names are the SSH and GPG forms rather than refs. - - **Proved against** - the paragraph following the agent snippet in `STANDUP.md` section 0 on `develop` at `676a2bd`, unchanged since `77be3a3`. - - **Delete when** - the sentence is reworded for any reason, since the entry is about this phrasing rather than about the rule it states. - -- **A hyphenated key such as `inputs.app-login` or `inputs.delete-branch` cannot be read with dot notation in a GitHub Actions expression, since `-` parses as subtraction, so the merge-bot task's `if:` conditions never match and its `env:` values never resolve.** Raised as four suppressed findings against `.github/workflows/merge-bot-task.yml`, each a variant of the one claim. - - **Disproved by** - reading the expressions reference, which states that property dereference syntax needs a name that starts with a letter or `_` and contains only alphanumeric characters, `-`, or `_`, so a hyphen is inside the allowed set and index syntax is required only for a name outside it. And by the same file, whose every job reads `steps.app-token.outputs.token` with dot notation on the same hyphenated shape, the expression the fleet's merge-bot has resolved on every Dependabot merge it has performed. `actionlint` parses the file without a finding. - - **Proved against** - `.github/workflows/merge-bot-task.yml` on `feature/reusable-workflows` at `210d88f`, and the "About contexts" property dereference rule in the GitHub Actions contexts reference read on 2026-08-15. - - **Delete when** - the task stops declaring hyphenated inputs, or the expressions reference changes the allowed set. - -## When in Doubt - -Read [AGENTS.md](../AGENTS.md) to find the section that governs your change, and [GOVERNANCE.md](../GOVERNANCE.md) for the rule text itself. For code-style rules, [`CODESTYLE.md`](../CODESTYLE.md) (its General section plus the relevant language section) is authoritative. Don't restate any of these files' rules in commit bodies or PR descriptions, and keep those focused on the change itself. - -If you find a gap in the governance itself (this file, AGENTS.md, or GOVERNANCE.md is out of date, a rule is missing, something bit this repo and would bite the next), fix it in the governance docs as part of your change rather than only working around it locally. +# Copilot Instructions + +Repository conventions for GitHub Copilot (and any other AI agent reading this file). + +The **canonical guide is [AGENTS.md](../AGENTS.md)** at the repo root. Read it first, then the [PR Review Etiquette](../GOVERNANCE.md#pr-review-etiquette) review-loop contract this file's runbook implements. This file is intentionally narrow: commit/PR-title conventions (summarized inline so VS Code's commit-message and PR-title generators have them), guidance for reviewing carried fleet content, plus the GitHub Copilot Review Runbook. + +For code-style rules, see [`CODESTYLE.md`](../CODESTYLE.md) at the repo root, one guide with a General section plus a section per language the repo uses. + +Do not duplicate language-specific rules here. **Project-specific conventions and API/behavioral contracts also belong in [GOVERNANCE.md](../GOVERNANCE.md), not here.** This file is intentionally limited to the inline commit/PR-title summary, the guidance for reviewing carried fleet content, and the GitHub Copilot Review Runbook. Non-Copilot agents (Claude Code, Codex, Cursor, ...) are not directed to this file and don't read it by default, so any rule a reviewer must honor has to live in `GOVERNANCE.md`, routed to from `AGENTS.md`, to be provider-independent. + +## Commit Messages and Pull Request Titles + +Summarized for VS Code's generators. The full rules, rationale, and examples are in [GOVERNANCE.md "Pull Request Title and Commit Message Conventions"](../GOVERNANCE.md#pull-request-title-and-commit-message-conventions). + +- Imperative subject, <= 72 characters, no trailing period, with an optional blank-line-separated body for the non-obvious *why*. +- US English, title case with lowercase short bind words. No vague titles, no `Co-Authored-By:` unless asked, no release-bump magnitude (NBGV handles versioning). Dependabot's `Bump X from Y to Z` titles are fine. +- develop PRs squash-merge (`gh pr merge --squash`), main PRs merge-commit (`--merge`). A mismatched flag is rejected by branch protection. + +## Reviewing Carried Fleet Content + +Several of this repository's governance files are carried from a shared template and kept in sync across a fleet of sibling repositories, among them `AGENTS.md`, `CODESTYLE.md`, `WORKFLOW.md`, this file, and the `repo-config/` rulesets. Most of `GOVERNANCE.md` is universal fleet law: every section that states a rule, as opposed to the two that describe this repository's own directory tree and devcontainer, is byte-locked and verified by an automated byte-for-byte match against the template canonical, not by line-by-line review. `AGENTS.md` is the thin router and carries three byte-locked sections of its own, `Fleet Bootstrap`, `Context and Delegation Discipline`, and `Where the Rules Live`. Everything above the first of those headings is this repository's own prose, saying what this repository is and which local doc owns which subject, and it is editable like any other repository content. + +Three constraints follow when reviewing that content. + +- **A reference inside byte-locked text to a path or section this repository does not carry is intentional, not a broken link.** Universal rule text names shared infrastructure (a fleet registry, a reusable config snippet, the other workflow model's ruleset payload) that a given repository legitimately may not contain. Editing the text to "fix" such a reference would break the fleet audit that governs it, so the reference is correct as written. Do not report it as a dead link, a missing file, or a broken cross-reference. +- **A genuine substantive defect is still worth raising.** Byte-locked is not unreviewable. A self-contradiction, a factual error, or a real typo in the canonical prose is a valid finding, but note that the fix lands at the template and re-vendors to every repository, rather than proposing a local edit the audit would reject. +- **A reference to a hub script is a pointer to follow, not a broken local path.** The fleet's gates live in one place and a repository runs them from a checkout of that place rather than holding a copy, so `scripts/prose_lint.py` (prose the CI linters pass on), `scripts/repo_gate.py` (repository settings and action pins), `scripts/pr_review.py` (the review digest, and reply plus resolve without a hand-typed id), and `spec/audit.py` (the conformance audit) resolve there and in none of the repositories they measure. [GOVERNANCE.md "Documentation Style Conventions"](../GOVERNANCE.md#documentation-style-conventions) carries the exception that permits such a pointer inside carried text, and [GOVERNANCE.md "Hub-Hosted Tooling"](../GOVERNANCE.md#hub-hosted-tooling) states how one is reached and what to report when it cannot be. Reach for them before writing a check of your own, since a reconstructed gate encodes its author's reading of a rule rather than the rule, and agrees with no other repository. + +## GitHub Copilot Review Runbook + +> This runbook implements the [GOVERNANCE.md "PR Review Etiquette"](../GOVERNANCE.md#pr-review-etiquette) review-loop contract for GitHub Copilot. Without it in-repo, an agent has no pointer to the reliable Copilot mechanics and falls back to known-broken paths (the no-op `POST /requested_reviewers`, the wrong bot-login filter). In the API snippets below, fill the `<owner>` / `<repo>` / `<N>` placeholders. + +Use this section for provider-specific mechanics. The expected review loop *contract* (request review on every push, verify head-SHA coverage, triage findings, reply + resolve, escalate when stuck) is defined in [GOVERNANCE.md -> PR Review Etiquette](../GOVERNANCE.md#pr-review-etiquette). This section only describes how to make GitHub Copilot reliably execute it. + +### Triggering and Polling + +Auto-review on push is configured (via the branch ruleset's `copilot_code_review` rule with `review_on_push: true`) but fires inconsistently in practice, so treat it as best-effort, not guaranteed. After every push, **re-request a review programmatically** via the GraphQL `requestReviews` mutation, passing the Copilot reviewer's bot node id in `botIds`. This drives the loop end-to-end without a UI hand-off. + +**A review with no inline comments is still a completed review, not a failure, and not a reason to ask the maintainer to re-trigger.** Copilot very often posts a single formal review (GraphQL `state: COMMENTED`) whose body ends with "...reviewed N of N changed files ... and generated no comments" and adds **zero** inline threads. That review carries the head `commit.oid` and fully satisfies the loop, and it is the clean-pass success case. Never read "no inline comments" as "the review didn't run," and never re-request or escalate to the maintainer because comments are absent. + +**The one exception is a review that says it did not review, and it is delivered in exactly that shape.** Copilot answers a pull request it will not take on with a formal review, `state: COMMENTED`, carrying the correct `commit.oid` and **zero** inline threads, whose whole body is a refusal: "Copilot wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again." Every coverage check passes, the rule above says an empty review is the clean pass, and the two together read a round that never happened as a round that found nothing. Observed on a pull request of 301 changed files, one over the limit, which was one command from merging on it. **The limit is 300 changed files and the remedy is to split the pull request**, since re-requesting the same head repeats the refusal: the file count is what it declined on and re-requesting does not change it. A repository committing binary or generated data alongside code crosses that line easily. Match the refusal on the body's **opening line** rather than anywhere in it, because a review discussing the wording is not one carrying it, and one line rather than two, because a review's first line is its heading and its second is the overview prose where such a description sits. Match an alternation for the same reason the suppressed heading takes one: + +```sh +# A review whose opening line declines the round. That line is the unit, since a refusal is +# the whole body and a match further down is a review quoting the wording rather than refusing. +# The dot spans both spellings of the apostrophe, the typographic one Copilot writes and the +# ASCII one, and it also keeps this filter usable inside single quotes, which neither survives. +gh api repos/<owner>/<repo>/pulls/<N>/reviews --jq \ + '.[] | select([(.body // "") | split("\n")[] | select(. != "")][0] // "" + | test("wasn.t able to review|was not able to review|unable to review")) | {commit_id, body}' +``` + +**Read the low-confidence findings, which are not inline threads.** A review body can carry a collapsed `<details>` block of findings Copilot withheld from the inline threads, and those findings appear nowhere in `reviewThreads`, so a loop that polls threads alone never sees them and reports a clean pass. **Match the block on more than one phrasing.** Its heading has appeared both as `Suppressed comments (N)` and as "Comments suppressed due to low confidence", so a filter keyed on either one alone silently reports zero suppressed findings on a review that has them, the same false clean this rule exists to prevent, one level up in the detection. **The section moves as well as it is worded, so match the heading wherever it sits.** It has appeared as its own `<details>` wrapper with a matching `<summary>`, as a bare heading in the body, and as a Markdown heading nested inside the `Review details` wrapper, whose `<summary>` names the wrapper and not the section. A filter reading a wrapper's `<summary>` reports zero on the nested shape, and the count it needs is the heading's own `(N)` rather than the wrapper's. They have been right repeatedly, including a rule stated more broadly than its check enforced and a check that skipped fenced blocks in every rule but one. Read the body of every review, investigate each suppressed finding on the same footing as an inline one, and answer it in the PR conversation, since a suppressed finding has no thread to reply on or resolve. + +```sh +# `test` with an alternation, not `contains` on one phrasing: the heading wording has changed. +gh api repos/<owner>/<repo>/pulls/<N>/reviews --jq \ + '.[] | select(.body | test("Suppressed comments|low confidence")) | .body' + +# Read every round, not only the head. A suppressed finding has no resolved state, so a push +# does not retire it: it simply stops appearing in a head-scoped query while still unanswered. +# Head-scoping this query is how four rounds went unanswered across three pull requests in a day. +gh api repos/<owner>/<repo>/pulls/<N>/reviews --jq \ + '[.[] | select(.body | test("Suppressed comments|low confidence"))] | length' + +# Mark which round each came from, since a finding on an older round may since be moot. +PR_HEAD=$(gh pr view <N> --json headRefOid --jq '.headRefOid') +gh api repos/<owner>/<repo>/pulls/<N>/reviews --jq \ + "[.[] | select(.body | test(\"Suppressed comments|low confidence\")) + | {round: (if .commit_id == \"$PR_HEAD\" then \"head\" else \"earlier\" end), id}]" +``` + +**Round 1 is normally auto-seeded, so poll for it before trying to self-trigger.** Auto-review-on-open supplies the first review with no `botIds` call needed, but it can lag one to three minutes, and on some pull requests it never fires at all. After opening a PR (or the first push), **poll** for a Copilot review on the head SHA (see [Verify Review Covered Current Head](#verify-review-covered-current-head)) before concluding none ran. Where it never lands, drive round 1 with the same `requestReviews` mutation every later round uses, which needs nothing this PR has to produce first. A round 1 carrying no review therefore means "wait, then request it yourself," **not** "ask the maintainer to kick it off." + +> **The reviewer login differs by API, in three forms rather than two.** In **GraphQL** (`gh api graphql` and `gh pr view --json reviews`, which is GraphQL-backed) the `Bot.login` is `copilot-pull-request-reviewer`, with **no `[bot]` suffix**. In the **REST** API (`gh api repos/.../issues|pulls/...`) the same account's `user.login` is `copilot-pull-request-reviewer[bot]`, **with** the suffix. In a REST **timeline** `review_requested` event the `requested_reviewer` is a third spelling again, login `Copilot` with `type` `Bot`, so a filter written against either of the other two selects nothing there and reports a pull request with requests as having none. Match on the type plus a loose login test rather than on any one spelling, and each query below uses the correct form for its API. + +```sh +# 1. PR node id, plus the reviewer bot's node id read across the repo's recent PRs. +# The bot id is the reviewer account's own, so every PR in the repo carries the same one. +# The reviewer login is `copilot-pull-request-reviewer` in GraphQL. +PR_NODE=$(gh pr view <N> --json id --jq '.id') +BOT_ID=$(gh api graphql -f query=' +{ + repository(owner: "<owner>", name: "<repo>") { + pullRequests(first: 20, orderBy: { field: CREATED_AT, direction: DESC }) { + nodes { reviews(first: 20) { nodes { author { __typename login ... on Bot { id } } } } } + } + } +}' --jq '[.data.repository.pullRequests.nodes[].reviews.nodes[] + | select(.author.login == "copilot-pull-request-reviewer") + | .author.id] | first // empty') +if [ -z "$BOT_ID" ]; then + echo "no Copilot review in the 20 most recent PRs, so widen the window" >&2 + return 1 2>/dev/null || exit 1 # Stop. Do NOT call requestReviews with an empty id. +fi + +# 2. Re-request a Copilot review on the current head. +gh api graphql -f query=' +mutation($pr: ID!, $bot: ID!) { + requestReviews(input: { pullRequestId: $pr, botIds: [$bot], union: true }) { + pullRequest { id } + } +}' -F pr="$PR_NODE" -F bot="$BOT_ID" +``` + +**The bot node id belongs to the reviewer account, not to a pull request**, and it is the same id on **every PR in the repo**, so nothing has to land on this PR before step 1 can read it. A PR opened a minute ago, with no review and no comment of its own, needs no UI seeding to bootstrap the id and no prior review to source it from: any Copilot review anywhere in the repo carries it. Query the **most recent** PRs, since a plain `last: 20` returns the *oldest* ones, which may predate Copilot on the repo. **Guard for an empty result**, because an empty `$BOT_ID` says only that none of the PRs sampled carry a Copilot review, so widen the window (raise the count or paginate) before concluding the repo has never had one. Never pass an empty id to the mutation. + +A read scoped to this PR (`pullRequest(number: <N>) { reviews }`) returns the same id once a review has landed here, and it buys nothing over the repo-wide read while failing on exactly the round the repo-wide read handles. Where the repo's only Copilot artifact is an issue comment rather than a formal review, read the id from that comment's author instead (`pullRequest.comments` -> author `... on Bot { id }`). Manual UI seeding is the last resort, needed only for a repo that has **never** had a Copilot review, so no prior id exists anywhere to read. + +**Do NOT post `@Copilot review` as a PR comment.** That comment triggers the Copilot *coding agent* (`copilot-swe-agent[bot]`), which makes code changes rather than posting a review. + +Known non-working request paths (don't rely on them, and use the `requestReviews` mutation above instead): + +- `POST /requested_reviewers` with `reviewers=[Copilot]` can return 200 but no-op. +- `copilot-pull-request-reviewer` as a requested reviewer slug returns 422. +- `requestReviews` with the reviewer's bot node id in **`userIds`** fails with `Could not resolve to User node`, because the Copilot reviewer is a **Bot**, so its node id goes in **`botIds`** (as in the mutation above), never `userIds`. +- `suggestedActors(capabilities: [CAN_BE_ASSIGNED])` lists `copilot-swe-agent` (the coding agent), not `copilot-pull-request-reviewer`, so do not source the reviewer's bot node id there. Read it from an existing review per step 1 above. +- There is no `removePullRequestFromReviewRequest` mutation, but removal is not therefore impossible: `requestReviews` **replaces** the reviewer set when `union` is false (the schema describes `union` as "add users to the set rather than replace"), so an empty `botIds` with `union: false` removes the pending request. Reach for it only in the stuck case below, since `union: true` re-fires a review on the current head without it. +- `gh pr view --json reviewRequests` **omits a Bot reviewer entirely**, reporting an empty set while Copilot sits in it. Read the pending set through GraphQL `reviewRequests`, which returns the `Bot` node, because the REST-backed projection makes a pending request read as no request at all. + +### Verify Review Covered Current Head + +Before merging, confirm Copilot reviewed the current PR head SHA. Copilot may respond as either a formal review (carries an exact commit SHA) or an issue comment (no SHA, so use the most recent Copilot comment for manual confirmation). Check both. + +**Count matches and compare numerically, so an empty result cannot read as success.** A poll that captures a `gh api --jq` result and exits on `[ "$found" != "0" ]` treats an **empty** string as a landed review, and an empty string is exactly what a mis-written filter returns. Pipe the matches through `wc -l` and test `-gt 0`, so a query that finds nothing and a query that ran wrong both read as "not yet". A `gh` call that fails to run reaches the test the same way, because it writes its message to stderr and prints nothing to stdout, so the `$(...)` around it still yields the empty string. A mistyped or unsupported flag is the usual cause, and `gh` reports one as `accepts 1 arg(s), received 4` rather than as anything resembling a review verdict. + +**Check head coverage before reading merge-state, never the reverse.** A push makes the required checks go green before Copilot re-reviews the new head, so `mergeStateStatus` can read `CLEAN` in the window before any formal review covers the head. A poll that exits on `CLEAN` merges into that gap. Gate on a formal review whose `commit.oid` equals the current head SHA first, then on zero unresolved threads, and only then read merge-state. + +```sh +PR_HEAD=$(gh pr view <N> --json headRefOid --jq '.headRefOid') + +# 1. Formal review - exact SHA match. +gh pr view <N> --json reviews --jq \ + '.reviews[] | select(.author.login=="copilot-pull-request-reviewer") | .commit.oid' \ + | grep -q "$PR_HEAD" && echo "covered via formal review" + +# 2. Issue comment - show the most recent Copilot comment for manual +# confirmation. This is the REST API, so the login carries the `[bot]` suffix. +gh api repos/<owner>/<repo>/issues/<N>/comments --jq \ + '[.[] | select(.user.login=="copilot-pull-request-reviewer[bot]")] | last | {created_at, body: .body[:200]}' +``` + +Coverage is confirmed when (1) exits 0, and **a formal review with no inline comments still satisfies path (1)**, because coverage is about the head SHA, not the comment count. The exception is the refusal above, which is a formal review on the head with no inline comments and covers nothing, so path (1) exits 0 over a round that never ran. Read the body of the review the SHA matched, not only the SHA. For issue comments (path 2), body content is the only reliable signal, and `created_at` is not: `git log -1 --format=%cI` is the **commit** timestamp, not the push timestamp, so amended or rebased commits can have an earlier timestamp and an older Copilot comment could satisfy a time check even though Copilot never saw the current head. Treat path (2) as confirmed only when the comment body explicitly refers to the current changes. + +**Coverage of the head is not coverage of the diff, and the second one is stated in a line nothing above reads.** A review body says how many of the pull request's changed files it read, and a round that read fewer than the pull request changed is byte for byte the clean pass in everything else: the same `commit.oid`, the same absent threads, the same "generated no comments". Measured over 332 Copilot review bodies on this repository, five rounds across three pull requests reported reading fewer files than were changed, and all three merged. One of them changed three files, left one unread across **both** its rounds, and reported no comments each time. This is the third instance of the shape the refusal above and the suppressed block below are the first two, so read it the same way: **fail closed on a wording you do not recognize**, since a gate that allows whatever it does not recognize stops gating as the wording drifts, and both of those wordings have drifted once already. + +Two spellings carry the count, and both are current rather than one superseding the other. Each opens its own line, which is what separates the round stating its coverage from prose mentioning changed files, that prose being what a review of a change to this rule looks like: + +```text +Copilot reviewed 2 out of 3 changed files in this pull request and generated no comments. +- **Files reviewed:** 2/3 changed files +``` + +The sentence tail after the first spelling reports how many comments the round raised and appears in four wordings. It is not coverage, so it is not part of what has to be recognized, and the counts are. Read them into three verdicts and two exemptions: + +- **Counts equal** - the round read the whole diff. This is the clean pass. +- **Counts unequal** - files in the diff have no review at all. Do **not** treat a re-request as the remedy: measured over four pull requests and seven rounds on this repository, every partial round stayed partial at the identical ratio and no round ever recovered, so re-requesting spends a round and changes nothing. Splitting works where it applies and does not apply to a promotion, whose head is `develop`. **The file table in the body does not tell you which file went unread**, and it looks as though it should, which is why it is written down here: measured over 348 review bodies on this repository and 121 on another in this fleet, that table names the whole changed set on partial and fully covered rounds alike, so a table naming every changed file is what a full round carries too and contradicts nothing. One round of the seven is the exception, stating 16 of 17 and naming 16, omitting `GOVERNANCE.md`, and `status` names an omitted file only in that shape, where the table is short by exactly what the counts leave unread and names nothing outside the diff. Treat that as a lead to check rather than a verdict, one round here naming `GOVENANCE.md`, the reviewer's own spelling and a path no diff carries. Report the state and hand the merge decision to the maintainer. +- **Coverage-shaped and unreadable** - the remedy is to fix the reader, not to read past it. The vetted spellings live in `scripts/pr_review.py` and here, and they stay in step because a case reads them out of this file. +- **Exempt: a body stating no coverage at all.** 28 of those 332 bodies are an overview and a change list and nothing more. That shape is current, interleaves with the counted one throughout, and one pull request carries both across its two rounds, so treating it as a failure cries wolf on about one review in twelve and teaches an agent to work around the gate. It reads as `coverage=unstated`, never as a pass and never as a failure. +- **Exempt: a refusal.** It carries no coverage line by design, and the refusal rule above has already classified it. Read it here as well and every refusal grows a spurious second failure on top of the one that names its remedy. + +`scripts/pr_review.py status <N> --repo <owner>/<repo>` reports this as `coverage=full`, `coverage=PARTIAL`, `coverage=UNVETTED` or `coverage=unstated`, and exits `42` on a partial round. An unreadable wording exits `43` instead, as one of the unrecognized shapes below rather than as a case of its own, since both say the reader is what needs fixing. Read it by hand as: + +```sh +gh pr view <N> --json reviews --jq \ + '.reviews[] | select(.author.login=="copilot-pull-request-reviewer") | .body + | split("\n")[] | select(test("^(Copilot|[-*] \\*\\*Files reviewed:).*changed files?"))' +``` + +### A Shape Nothing Recognizes Blocks the Loop and Earns an Issue + +**Every rule above keys on a marker in what Copilot sent, so a marker that changes spelling is a section the reader stops finding and reports as absent.** That is not a hypothetical: all three failures on record here have exactly that shape. The suppressed heading was reworded and the count went to zero. The suppressed section moved inside another wrapper and the count went to zero again. The coverage line was never read at all. Each one reported a clean pass over a review it had misread, and each was caught by the maintainer after it had already landed, rather than by the gate. + +**So an unrecognized shape is a blocking outcome, and its remedy is an issue rather than a judgment call.** When any reader here meets a heading, a collapsed section, a metadata line, a coverage wording or a reviewer login it has no vetted spelling for, the review loop **does not close**, whatever else the digest says. Do not read past it, do not infer what the new wording probably means, and do not treat a body that looks clean as a clean review, because "looks clean" is precisely what a misread review looks like. Two things follow, in this order: + +1. **File an issue on the hub**, the repository `AGENTS.md` "Fleet Bootstrap" names, which hosts `scripts/pr_review.py` and holds the vetted inventory. Name each unrecognized shape and quote the review body it came from, so the fix is made against the real wording rather than a paraphrase. The issue is filed even when the shape turns out to be cosmetic, since "cosmetic" is a conclusion drawn after reading the body and not before. +2. **The merge decision is the maintainer's**, not the agent's and not the script's. An unrecognized shape does not mean the pull request is bad, it means nothing here can vouch for the review of it. Report the state, hand it over, and stop. + +`scripts/pr_review.py status <N> --repo <owner>/<repo>` reports this as `shapes=UNRECOGNIZED`, lists each shape under a marker naming the remedy, and exits `43`. `wait` carries the same code, so a wait cannot end on a clean zero over output nothing read. The vetted inventory lives in that script and is small on purpose: measured over 332 Copilot review bodies on this repository, with fenced blocks dropped and text reduced to ASCII, the whole corpus is seven headings, six `<summary>` texts and three metadata labels, and every body carries at least one of them. A body carrying none is itself the unrecognized shape, which is what catches a rewrite that changes everything at once, the refusal wording drifting among it. + +### Bounded Retry Workflow + +This path is only for a **genuinely missing** review, meaning no Copilot review (formal *or* issue comment) covers the current head SHA after polling. A review that covered the head but produced no comments is a clean pass, not a missing review, so do not enter this retry path for it. + +**A slow review is pending, not missing, so poll with backoff and never escalate on a timeout alone.** Copilot can lag far beyond the usual one-to-three minutes when it has been re-requested many times in quick succession, because it throttles under load, and a re-review landing tens of minutes after the request is normal. A poll that times out is therefore evidence only that the review has not landed *yet*, not that Copilot is done or unresponsive. Report the status as "review still pending" and keep polling on a widening interval (for example 20s steps, then a few minutes) rather than stopping. Enter the escalation step below only when the `requestReviews` mutation itself no-ops or errors, or after a genuinely long wait with the request confirmed accepted, never merely because one fixed poll window elapsed. + +**Bound each wait, and read what Copilot actually posted before opening another one.** A poll that widens forever is indistinguishable from a poll that has stopped, and "still pending" is the honest report for exactly as long as evidence supports it. Two readings decide whether waiting again is warranted. Compare the request's timestamp against the newest Copilot activity of **any** kind on the pull request, since a reviewer that has already answered on a later head, or that posted an issue comment instead of a formal review, is not a reviewer running late, and a wait that keeps reporting "pending" against a landed review is a broken wait rather than a slow reviewer. Then read that newest response, because a Copilot answer naming a quota or a rate limit is a **terminal** outcome rather than a pending one: no formal review will land, so path (1) never matches the head and path (2) is correctly never confirmed, both paths behave exactly as specified, and the agent waits for something that is not coming. The fix is account-side and re-requesting does not change it, so report it to the maintainer and stop waiting. Where the newest response is neither a review nor a refusal you recognize, that too goes to the maintainer with its text, rather than being waited through. + +**A pending request nothing picked up is a third state, and it is the one that looks most like patience.** Copilot raises a `copilot_work_started` timeline event within about half a minute of accepting a request, and submits its review a few minutes later. A request that never draws one is not a slow review, it is a request nothing is acting on, and it stays that way indefinitely: one sat for thirteen and a half hours while the pull request read as waiting on the reviewer. Elapsed time cannot tell the two apart, since a genuinely slow round also shows no review, so read the event rather than the clock. `copilot_work_started` appears in the REST timeline only, and no GraphQL timeline item carries it: + +```sh +# The pending set (GraphQL, since the `gh pr view` projection cannot see a Bot reviewer). +gh api graphql -f query=' +{ repository(owner:"<owner>",name:"<repo>"){ pullRequest(number:<N>){ + reviewRequests(first:10){ totalCount + nodes{ requestedReviewer{ __typename ... on Bot{login} ... on User{login} } } } } } }' + +# The request and pickup events, newest last. A `review_requested` with no later +# `copilot_work_started` is the stuck state. Requests are filtered to the reviewer's own, +# since a human requested afterwards is a different request and reading it as this one +# reports a picked-up review as never picked up. `per_page` is the pagination cost. +gh api --paginate 'repos/<owner>/<repo>/issues/<N>/timeline?per_page=100' \ + --jq '.[] | select(.event == "copilot_work_started" or (.event == "review_requested" + and .requested_reviewer.type == "Bot" + and ((.requested_reviewer.login // "") | ascii_downcase | test("copilot")))) + | "\(.event) \(.created_at)"' +``` + +**Recover it by clearing the request and requesting again**, because the pull request UI offers no re-request control while a request is pending, and `requestReviews` with `union: true` adds a reviewer already in the set, which changes nothing. Read the pending set first, since `union: false` replaces the whole set and would drop a human reviewer requested alongside the bot. Where the clear-and-request does not draw a `copilot_work_started` within a minute or so, push a commit instead, since a new head raises a fresh request rather than poking a stale one. + +```sh +PR_NODE=$(gh pr view <N> --json id --jq '.id') +# 1. Clear. `union: false` replaces the set, so an empty botIds removes the pending request. +gh api graphql -f query=' +mutation($pr: ID!) { + requestReviews(input: { pullRequestId: $pr, botIds: [], union: false }) { + pullRequest { reviewRequests(first: 10) { totalCount } } } +}' -F pr="$PR_NODE" +# 2. Request again, against a now-empty set, with $BOT_ID read as in "Triggering and Polling". +gh api graphql -f query=' +mutation($pr: ID!, $bot: ID!) { + requestReviews(input: { pullRequestId: $pr, botIds: [$bot], union: true }) { + pullRequest { reviewRequests(first: 10) { totalCount } } } +}' -F pr="$PR_NODE" -F bot="$BOT_ID" +``` + +If a review did not run on the current head, retry: + +1. Wait briefly and check head-SHA coverage (see above). +1. Re-request the review via the `requestReviews` mutation (see "Triggering and Polling"), falling back to the GitHub PR UI only if the mutation no-ops. +1. Retry up to two more times (three total). +1. If still missing, mark review as blocked and escalate to the user/maintainer with what was attempted. + +### Reply and Thread Resolution Workflow + +Every id below is captured from a live query into a variable and passed from there, never hand-typed, guessed, or pasted as a `PRRT_...` literal. A node id resolves globally, so a fabricated or stale id does not fail, it writes to a real thread on an unrelated repository. This runbook implements [GOVERNANCE.md "Repository Boundaries and Write Safety"](../GOVERNANCE.md#repository-boundaries-and-write-safety): write only to this repo, capture every id from a live query, and never suppress a mutation's output. + +**Use the hub's helper, which has nowhere to type an id.** `scripts/pr_review.py reply <N> --repo <owner>/<name> --match "<words from the finding>" --body "<answer>" --resolve` queries the thread id itself and passes it straight to the mutation. That rule is known and read by the agents that break it anyway, three times so far, so the shape is what changes rather than the wording. It selects on the finding's own words rather than a line number, since a fix push moves the line; it refuses on no match and on more than one rather than picking; and it does not resolve a thread whose reply came back without a `url`. Cross-owner targets it refuses outright, which is where the hand-run form below applies, and there the `gh-write-guard` hook is what reads the maintainer's grant. It is hub-hosted per [GOVERNANCE.md "Hub-Hosted Tooling"](../GOVERNANCE.md#hub-hosted-tooling), so it is invoked from a hub checkout and never rebuilt locally. + +The hand-run form is below, for a cross-owner target and for the case where the hub cannot be reached and the work cannot wait. + +List unresolved threads. Use `first: 100` with cursor-based pagination, and where `hasNextPage` is true, re-run with `after: "<endCursor>"` to retrieve the next page: + +```sh +gh api graphql -f query=' +{ + repository(owner: "<owner>", name: "<repo>") { + pullRequest(number: <N>) { + reviewThreads(first: 100) { + nodes { + id isResolved path + comments(first: 1) { nodes { author { login } body } } + } + pageInfo { hasNextPage endCursor } + } + } + } +}' | jq ' + .data.repository.pullRequest.reviewThreads | + (.pageInfo | "hasNextPage=\(.hasNextPage) endCursor=\(.endCursor)"), + (.nodes[] | select(.isResolved == false)) +' +``` + +Reply on a thread, then resolve it. Capture the target thread's id into `$TID` from the listing query above, filtering to the thread being answered by its `path`, and guard for an empty result so a mutation never runs on a guessed id. When a file carries more than one unresolved thread, `path` alone is ambiguous and `head -n 1` would pick the wrong one, so narrow by first-comment body (the query already fetches `comments(first: 1)` for this) by adding `and (.comments.nodes[0].body | contains("<SNIPPET>"))` to the `select`: + +```sh +TID=$(gh api graphql -f query=' +{ + repository(owner: "<owner>", name: "<repo>") { + pullRequest(number: <N>) { + reviewThreads(first: 100) { + nodes { id isResolved path comments(first: 1) { nodes { body } } } + } + } + } +}' --jq '.data.repository.pullRequest.reviewThreads.nodes[] + | select(.isResolved == false and .path == "<PATH>") + | .id' | head -n 1) +[ -n "$TID" ] || { echo "no matching unresolved thread on <PATH> - do not guess an id" >&2; return 1 2>/dev/null || exit 1; } + +# Show the mutation's output. Never append an output-discard or force-success tail +# (>/dev/null, 2>/dev/null, &>/dev/null, || true, || :, || echo) to a write. +gh api graphql -f query=' +mutation($threadId: ID!, $body: String!) { + addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $threadId, body: $body }) { + comment { id url } + } +}' -F threadId="$TID" -F body="Fixed in <SHA>: <one-line summary>." + +# Confirm isResolved: true in this response before treating the thread as closed - a write that +# appears to fail may have taken on the server. +gh api graphql -f query=' +mutation($threadId: ID!) { + resolveReviewThread(input: { threadId: $threadId }) { thread { id isResolved } } +}' -F threadId="$TID" +``` + +Issue-level Copilot comments (those in `issues/<N>/comments`) have no resolution action, since GitHub provides no API or UI to resolve them. Reply if the finding warrants it, but no resolution step is needed or possible. + +### PR Edits and Merge-State Gotchas + +- **`gh pr edit --title/--body` is broken on `gh` 2.45.x and 2.46.x, and works from 2.47 up.** Those releases touch the deprecated Projects-classic `projectCards` GraphQL field and **exit non-zero without applying the change** (a stale PR description then survives review rounds), which the GitHub CLI maintainers name as broken by deprecated APIs. A distribution package is where that version comes from, so check `gh --version` before concluding the command is unusable, and install from the official repository rather than working around it. Where a host is genuinely stuck on one, edit via the API and verify it took: GraphQL `updatePullRequest(input: { pullRequestId, title, body })`, or REST `gh api -X PATCH repos/<owner>/<repo>/pulls/<N> -F body=@body.md` (the `@` reads the body from a file, so name it explicitly, not the literal `file`). The same version range carries no `--json` flag on `gh pr checks`, so a watcher built on it prints nothing and a quiet result reads as a passing one. +- **`main`/`develop` use rulesets, not classic branch protection.** The classic protection REST endpoint (`repos/.../branches/<b>/protection`) 404s, so read the ruleset instead. A `mergeStateStatus` of `BLOCKED` on a green PR is most often just **unresolved review threads** (the ruleset requires thread resolution), and resolving them moves it to `CLEAN`. (`BLOCKED` is a `mergeStateStatus` value, so don't confuse it with the separate `mergeable` field's `MERGEABLE`/`CONFLICTING`, which reports merge conflicts, not review gates.) +- **`BLOCKED` never says which gate, so never infer one.** The same word covers a red check, a required check nothing is running, an unresolved thread, and a missing approval, and the bullet above says "most often" rather than "always" for that reason. Read the checks instead of guessing: `pr_review.py status` prints `checks=N/M` beside the merge word and names a stuck one, and it exits `44` from `wait` where the merge reads `BLOCKED`, the review loop closed, and a check is starved, expected and never posted, running far past what the job costs, or failed. A **queued check with no runner** is the case that reads exactly like patience: a run here polled `BLOCKED` for twenty-five minutes on a pull request whose only unfinished check was an aggregator job GitHub dispatched and never assigned a runner, and the cause came from the maintainer rather than from any field. Nothing agent-side starts that job, because the pool is GitHub-hosted, so the remedy is a re-run of the workflow or waiting on that capacity, and it is **not** a re-request, a rebase, or an empty commit. A job held behind a `needs:` dependency does not enter the rollup until that dependency finishes, so a queued check is never a dependency waiting its turn. +- **Push -> head-SHA read race.** A `headRefOid` read taken immediately after a push can return the **old** head, so re-read after the push registers, or a coverage poll evaluates the stale SHA. +- **Copilot is sometimes factually wrong** (e.g. it claimed `actionlint -color` "requires a value" when it is a boolean flag). Verify a finding before fixing, and decline with evidence when it is wrong, which is distinct from dismissing a still-present finding as stale. The evidence goes under [Disproved Claims](#disproved-claims) as well as in the thread, because the thread closes with the pull request and the next round starts without it. + +Reply-body conventions: + +- Accepted bug/style fix: include fixing commit SHA and a one-line summary. +- Declined style comment: cite the rule (GOVERNANCE.md or the CODESTYLE.md language section) and the existing-tree precedent. +- Declined architecture proposal: one-sentence rationale. +- Declined false positive on carried fleet content (a broken-link or dead-cross-reference flag inside byte-locked rule text): cite the "Reviewing Carried Fleet Content" section, since the reference is intentional and the text cannot be edited locally. + +After the final push, sweep-resolve stale older threads for removed code paths. + +### Disproved Claims + +**A disproof is proof about this repository, and the thread it was written in is not where the next round looks.** [GOVERNANCE.md "PR Review Etiquette"](../GOVERNANCE.md#pr-review-etiquette), which routes to the `pr-review-conduct` Skill, closes a false finding by disproving it in the thread, addressed to the reviewer so it does not raise the same thing again, and while the pull request is open that is the right place for it. Afterwards it is the wrong one. The pull request merges, the next round begins with no memory of the last, and the second occurrence reaches a maintainer with no way to tell it from a first. Each entry below is a claim that was tested against this repository and found false, kept so the proof is read rather than built twice. + +**An entry names the claim, what was run or read to disprove it, the revision it was proved against, and what ends it.** A disproof is true of one tree at one revision, so an entry whose subject moves is deleted by the change that moves it rather than edited to look current, which is the same sweep the [GOVERNANCE.md "Documentation Style Conventions"](../GOVERNANCE.md#documentation-style-conventions) rule already requires of prose asserting a behavior that has changed underneath it. This is deliberately not a list to append to, since an entry outliving the code it was proved against becomes a reason not to check, and that is strictly worse than proving the claim a second time. + +**The record answers a repeated claim and never dismisses a new one.** An entry is cited only where the revision it names is still what the tree carries, and the reply carries the proof re-read rather than a pointer to the entry, since a reviewer that cannot open this file learns nothing from being pointed at it. Judge a finding on its merits first and match it against this record second, because reading it the other way round is how a real finding gets closed by a stale proof. + +**The entries are this repository's own.** Each names a file and a revision, so a repository holding a copy of this file carries the shape and the rules above rather than these findings, deletes an entry whose subject it does not carry, and records what it has proved itself. + +- **`keys_unsorted` requires jq 1.6, so the ruleset normalizer in `repo-config/configure.sh` fails to compile on jq 1.5.** Raised as a suppressed finding, by analogy to the `walk/1` call the same filter was rewritten to avoid. + - **Disproved by** - running both builtins on `jq-1.5-1-a5b5cbe`, the build that reproduces the `walk/1` failure. `keys_unsorted` evaluates there and the whole normalizer returns the sorted document, while `walk(.)` on that binary answers `jq: 1 compile error`. The two builtins are not in the same position, and the analogy is the whole of what carried the finding. + - **Proved against** - the `norm` filter in `repo-config/configure.sh` on `develop` at `756a53e`. + - **Delete when** - the filter stops calling `keys_unsorted`, or nothing this check runs on carries a jq older than 1.6. + +- **Splitting the fallback parse in `host-setup/agent-safety/gh-write-guard.py` a line at a time mis-reads a newline inside a quoted argument, reintroducing the false deny that path exists to remove.** Raised against the branch that made a newline end a command, on the ground that a `--body` argument holding a newline and a `git push origin develop` would have that line read as a push. + - **Disproved by** - the arm being unreachable, and then by measuring it rather than resting on that. `punctuation_chars` arrived in Python 3.6, the module uses f-strings throughout, and `install.py` refuses to install below 3.7, so an interpreter that would raise the `TypeError` fails to import the module before reaching the fallback. Simulated against a `shlex` that rejects the keyword and passes everything else through, the quoted-newline example is allowed on both paths, because splitting a line whose quoting cannot be parsed leaves the quote glued to the token and the push target reads as `develop"`, matching no branch. The shape does bite one line further out, where a three-line body whose middle line is a bare `git push origin develop` denies on the forced path, and the alternative is worse where it counts: parsing the whole command at once keeps a quoted newline intact and drops every real one, so an ordinary push followed by a `gh pr create` denies under every interpreter rather than under none. + - **Proved against** - `_git_subcommand_arglists` in `host-setup/agent-safety/gh-write-guard.py` and the interpreter floor in `host-setup/agent-safety/install.py`, on `develop` at `dbd1cdc`. + - **Delete when** - the floor drops below 3.6, or the fallback stops splitting the command a line at a time. + - **Earned anyway** - a test case rather than a change. Only `ValueError` from unbalanced quoting reaches that path in practice and nothing covered it, so a finding wrong about its own reachability was right that the path was untested. + +- **A description's stale commit claims are found by extracting the bare SHAs it quotes.** Not a reviewer's finding but the method this repository's own backlog specified for the `claims` check in `scripts/pr_review.py`, recorded here because a rejected method costs the same to re-propose as a declined finding costs to re-derive, and because a backlog has a place for a claim the tree contradicts and none for a method a measurement rejects. + - **Disproved by** - running it over the 25 most recent merged pull requests, where it raised four references and all four were correct prose: a `develop` commit named as history, a SHA inside a pasted digest, and two commits in another repository written without a URL. Nothing in the shape of a bare SHA separates those from a claim, and separating them by meaning is the similarity heuristic [`spec/section-model.md`](../spec/section-model.md) rules out. A path arm measured on the same corpus is worse, flagging 54 of 215 backticked candidates, nearly all of them bare basenames and other repositories. + - **Proved against** - the 25 most recent merged pull requests as of `develop` at `756a53e`, the corpus on which the anchored verb form that ships instead raises one reference, and that one true. + - **Delete when** - `claims` stops reading a description for commit references. + +- **The GraphQL `pullRequests` connection defaults to `states: [OPEN]`, so the bot node id query in "Triggering and Polling" returns nothing in a repository whose Copilot-reviewed pull requests have all merged.** Raised against the repo-wide read, on the ground that a cold start is exactly the case where no open pull request carries a review. + - **Disproved by** - running the connection both ways against this repository while exactly one pull request was open. With `states` omitted, `pullRequests(first: 5, orderBy: { field: CREATED_AT, direction: DESC })` answers `628 OPEN`, `627 MERGED`, `626 MERGED`, `625 MERGED` and `624 MERGED`, so the omitted default is every state rather than `OPEN`. The same call with `states: [OPEN]` answers `628 OPEN` alone, which is the behavior the finding predicts for the first form and is what distinguishes them. The read was first run when this repository had no open pull request at all, and it returned the id from merged ones. + - **Proved against** - the `BOT_ID` query in "Triggering and Polling" in this file, run against this repository's pull request list on 2026-08-08. + - **Delete when** - that query names `states` explicitly, or stops reading pull requests to find the id. + +- **"The agent check branches" in `STANDUP.md` section 0 is a subject-verb disagreement, and should read "The agent checks branches".** Raised as a suppressed finding against a line the change under review only touched as diff context. + - **Disproved by** - reading the sentence against the snippet it describes. The subject is the noun phrase "the agent check", meaning the check for the signing agent, and "branches" is its verb, which is what the `if [ ... = ssh ]; then ssh-add -L; else gpg --list-secret-keys; fi` line does. The proposed reading needs "branches" as a plural noun, and the paragraph is section 0, before a repository exists, where the alternatives it names are the SSH and GPG forms rather than refs. + - **Proved against** - the paragraph following the agent snippet in `STANDUP.md` section 0 on `develop` at `676a2bd`, unchanged since `77be3a3`. + - **Delete when** - the sentence is reworded for any reason, since the entry is about this phrasing rather than about the rule it states. + +- **A hyphenated key such as `inputs.app-login` or `inputs.delete-branch` cannot be read with dot notation in a GitHub Actions expression, since `-` parses as subtraction, so the merge-bot task's `if:` conditions never match and its `env:` values never resolve.** Raised as four suppressed findings against `.github/workflows/merge-bot-task.yml`, each a variant of the one claim. + - **Disproved by** - reading the expressions reference, which states that property dereference syntax needs a name that starts with a letter or `_` and contains only alphanumeric characters, `-`, or `_`, so a hyphen is inside the allowed set and index syntax is required only for a name outside it. And by the same file, whose every job reads `steps.app-token.outputs.token` with dot notation on the same hyphenated shape, the expression the fleet's merge-bot has resolved on every Dependabot merge it has performed. `actionlint` parses the file without a finding. + - **Proved against** - `.github/workflows/merge-bot-task.yml` on `feature/reusable-workflows` at `210d88f`, and the "About contexts" property dereference rule in the GitHub Actions contexts reference read on 2026-08-15. + - **Delete when** - the task stops declaring hyphenated inputs, or the expressions reference changes the allowed set. + +## When in Doubt + +Read [AGENTS.md](../AGENTS.md) to find the section that governs your change, and [GOVERNANCE.md](../GOVERNANCE.md) for the rule text itself. For code-style rules, [`CODESTYLE.md`](../CODESTYLE.md) (its General section plus the relevant language section) is authoritative. Don't restate any of these files' rules in commit bodies or PR descriptions, and keep those focused on the change itself. + +If you find a gap in the governance itself (this file, AGENTS.md, or GOVERNANCE.md is out of date, a rule is missing, something bit this repo and would bite the next), fix it in the governance docs as part of your change rather than only working around it locally. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7b5319ff..80b9d3aa 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,28 +1,28 @@ -# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file -# -# The github-actions ecosystem is the only one this repo ships. -# Both main and develop are targeted so each stays current independently of the develop to main release cadence. -# The merge-bot auto-merges each base with its per-base method. -# See catalog/snippets/configs/dependabot.yml for the multi-ecosystem reference, covering nuget and uv, that a code-shipping repo uses. -version: 2 -updates: - - - package-ecosystem: "github-actions" - target-branch: "main" - directory: "/" - schedule: - interval: "daily" - groups: - actions-deps: - patterns: - - "*" - - - package-ecosystem: "github-actions" - target-branch: "develop" - directory: "/" - schedule: - interval: "daily" - groups: - actions-deps: - patterns: - - "*" +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +# +# The github-actions ecosystem is the only one this repo ships. +# Both main and develop are targeted so each stays current independently of the develop to main release cadence. +# The merge-bot auto-merges each base with its per-base method. +# See catalog/snippets/configs/dependabot.yml for the multi-ecosystem reference, covering nuget and uv, that a code-shipping repo uses. +version: 2 +updates: + + - package-ecosystem: "github-actions" + target-branch: "main" + directory: "/" + schedule: + interval: "daily" + groups: + actions-deps: + patterns: + - "*" + + - package-ecosystem: "github-actions" + target-branch: "develop" + directory: "/" + schedule: + interval: "daily" + groups: + actions-deps: + patterns: + - "*" diff --git a/.gitignore b/.gitignore index 3375dd1b..4c4ad48e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,30 +1,30 @@ -[Dd]ebug/ -[Rr]elease/ -[Bb]in/ -[Oo]bj/ - -.idea -.vs -.artifacts -.DS_Store -*.user -.claude - -__pycache__/ -*.py[cod] -*.egg-info/ -.venv/ -dist/ -.pytest_cache/ -.ruff_cache/ -.pyright/ - -# Coverage output (dotnet XPlat/coverlet, Python coverage.py) -coverage/ -[Tt]est[Rr]esults/ -*.cobertura.xml -*.coverage -coverage.xml -.coverage -.coverage.* -htmlcov/ +[Dd]ebug/ +[Rr]elease/ +[Bb]in/ +[Oo]bj/ + +.idea +.vs +.artifacts +.DS_Store +*.user +.claude + +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +dist/ +.pytest_cache/ +.ruff_cache/ +.pyright/ + +# Coverage output (dotnet XPlat/coverlet, Python coverage.py) +coverage/ +[Tt]est[Rr]esults/ +*.cobertura.xml +*.coverage +coverage.xml +.coverage +.coverage.* +htmlcov/ diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index 89ff90de..ed90f846 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -1,18 +1,18 @@ -{ - "config": { - // Prose paragraphs and data-heavy tables or URLs are intentionally long. - // Reflowing at 80 columns hurts readability and churns diffs. - "MD013": false, - // 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. - // Every other element still flags. - "MD033": { "allowed_elements": ["details", "summary"] }, - // Require fenced code blocks over the legacy 4-space-indented style. - "MD046": { "style": "fenced" }, - // MD060 (table column style) is not enforced - allow both compact - // (`|a|b|`) and padded (`| a | b |`) table pipe spacing. - "MD060": false - }, - "gitignore": true -} +{ + "config": { + // Prose paragraphs and data-heavy tables or URLs are intentionally long. + // Reflowing at 80 columns hurts readability and churns diffs. + "MD013": false, + // 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. + // Every other element still flags. + "MD033": { "allowed_elements": ["details", "summary"] }, + // Require fenced code blocks over the legacy 4-space-indented style. + "MD046": { "style": "fenced" }, + // MD060 (table column style) is not enforced - allow both compact + // (`|a|b|`) and padded (`| a | b |`) table pipe spacing. + "MD060": false + }, + "gitignore": true +} diff --git a/AGENTS.md b/AGENTS.md index fff1fd30..9f185b94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,102 +1,102 @@ -# Instructions for AI Coding Agents - -**ProjectTemplate** exists for agent enablement across a fleet of related projects, so an agent works over all of them autonomously and productively, at repeatable quality, inside guardrails that keep the autonomy from doing harm. It holds the portable rules those projects follow, a machine-readable ground-truth spec ([`spec/`](./spec/)), a registry of the projects ([`registry/repos.json`](./registry/repos.json)), and an audit-agent instruction set ([`AUDIT.md`](./AUDIT.md)). It ships no sample application code. - -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), and the CI/CD workflow contract in [`WORKFLOW.md`](./WORKFLOW.md). - -Treat this file and `GOVERNANCE.md` as authoritative for cross-cutting rules, and do not restate their rules elsewhere. A project's **project-specific conventions and public-API/behavioral contracts** (e.g. a "Library API Conventions" section) live in that project's own `AGENTS.md`, **not** in [`.github/copilot-instructions.md`](./.github/copilot-instructions.md), because that file targets GitHub Copilot / VS Code specifically, while this file and `GOVERNANCE.md` are the agent-agnostic ones every coding agent is directed to read, so any rule a reviewer must honor has to live in one of those two files to be provider-independent. - -## Fleet Bootstrap - -This repository is governed by a shared template, and the canonical rules, machine-readable spec, and procedures live in `github.com/ptr727/ProjectTemplate`, the repository these rules call the hub. Fetch that repository before acting on anything about conformance, carried content, repository settings, or standing a repository up, because a carried copy here can be stale or absent and the hub is the only authority on what this repository is supposed to hold. This section is byte-locked across every repository in the fleet, so it reads identically wherever it is found, and it is the entry point whenever nothing else present says where the rules are. - -Route by what this repository currently holds rather than by what it is expected to hold, since the two differ exactly when this section matters most. - -```mermaid -flowchart TD - state["what does this repository currently hold?"] - state -->|"no repo, or a local tree with no remote"| standup["hub STANDUP.md, from section 0"] - state -->|"no carried instruction set, or a partial one"| standup2["hub STANDUP.md sections 1A, 2"] - state -->|"instruction set present, current or stale"| resync["hub RESYNC.md"] - state -->|"believes it is conformant"| resync2["hub RESYNC.md, run the audit anyway"] -``` - -- **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. - -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. - -### Session Scope - -- **One deliverable, one session.** A session covers one branch and one deliverable, and ends when that work merges. A multi-step task is one deliverable and stays in one session. Two unrelated tasks are two sessions even when they run back to back. -- **End a session at any of these, without being asked:** the branch changes, the pull request merges, or the next task is unrelated to the last. A review round is none of them. A loop still producing findings is the deliverable in progress, and a round count is not a reason to leave one open. -- **Hand off in a file, never in context.** Close a session by writing at most 2 KB to a scratch file: branch, pull request link, what is done, the next command. A summary held in context is re-billed until the session ends, and a summary on disk is read once by whoever needs it. -- **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. - -### Reading - -- **Map a large file, then read one range.** For anything over about 200 lines, list the headings with `grep -n '^## '` first and read only the range the task needs. Read the section, not the file that contains it. -- **Prefer an in-place edit to a whole-file rewrite.** Rewriting a file bills its full content again on top of what the read already cost. - -### Commands - -- **Bound output at the source.** Write every command so its output is the answer, not the haystack: a `--jq` projection on an API call, a count or files-only flag on a search, a summary flag on a diff, an explicit cap on anything unbounded. A command whose output you then skim is a command that should have been narrower. -- **Keep a long query in a file, not in the command.** A heredoc re-typed on every call costs its own length in context each time, often more than the answer it retrieves. - -### Delegation - -- **Delegate exploration, keep judgment.** A subagent starts from an empty context and returns only its conclusion, so a wide search, a multi-file audit, or a "which of these is affected" question costs a fraction of the same work inline. Delegate when the finding compresses to a short answer, and stay inline when the intermediate detail drives the next edit. -- **Match the model tier to the judgment, not to the diff size.** Mechanical work (a known-shape edit repeated across files, an extraction, a status check, a lint fix) runs on the cheapest model that does it correctly, at the lowest reasoning effort that holds. State the tier in the delegation itself rather than accepting the default. A change to a gate, a ruleset, a release condition, or a carried governance section is a design change however small it looks. -- **Never tier down the seat holding the judgment.** Governance wording, spec logic, rulesets, repository visibility, and the decision to decline a review finding are fleet-wide and durable when wrong. Tier the subagents, not the main thread. -- **Brief a subagent so it never needs a governance file.** A subagent inherits no context, so anything it must honor has to be in its prompt. Reading `GOVERNANCE.md` to find out costs it the same tokens the main thread would have paid. Brief on this shape: - -```text -Task: <the one question or edit, stated so the answer compresses> -Paths: <exact files or globs - never "find the relevant files"> -Rules that bind this task: <the specific rules, quoted, not a pointer to a doc> -Return: <the shape of the answer - a list, a diff, a yes/no with evidence> -Bounds: <what not to touch, and what to do when a rule looks incomplete> -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. -- **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. - -## Where the Rules Live - -Every rule below is a level-two section of [`GOVERNANCE.md`](./GOVERNANCE.md). Read the section the task needs. - -| 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 | -| 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 | -| 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` | -| 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 | -| Requesting, answering, or closing a review | `PR Review Etiquette`, packaged as the `pr-review-conduct` Skill | -| Reporting progress or asking the user something | `Communicating with the User`, surfaced at its decision moment by the `agent-conduct` Skill, and the section keeps the full rules | -| Editing a workflow YAML file | `Workflow YAML Conventions`, surfaced with the full `WORKFLOW.md` contract by the `workflow-ci-contract` Skill, and this section and `WORKFLOW.md` keep the full rules | -| Choosing an OS, runtime, or toolchain target | `Supported Development Platforms` | -| The devcontainer | `Devcontainer` | -| Editor settings and tasks | `Editor and Tasks` | -| The About panel, description, or repo toggles | `Repository Details` | -| Where a file belongs in the tree | `Repository Layout` | - -A row above with no Skill annotation is doc-only by decision, not by omission. A Skill surfaces rules at a trigger moment, and each unannotated section either binds always or carries no moment narrower than reading it: `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 must bind even when no Skill fires (the `gh-write-guard` hook and the host-wide instruction blocks the agent-safety installer maintains are their enforcement layer, and the one moment in the boundaries section narrow enough to surface, isolating into a worktree at task start, gets the `repo-worktree` Skill on top of that law rather than instead of it), 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. +# Instructions for AI Coding Agents + +**ProjectTemplate** exists for agent enablement across a fleet of related projects, so an agent works over all of them autonomously and productively, at repeatable quality, inside guardrails that keep the autonomy from doing harm. It holds the portable rules those projects follow, a machine-readable ground-truth spec ([`spec/`](./spec/)), a registry of the projects ([`registry/repos.json`](./registry/repos.json)), and an audit-agent instruction set ([`AUDIT.md`](./AUDIT.md)). It ships no sample application code. + +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), and the CI/CD workflow contract in [`WORKFLOW.md`](./WORKFLOW.md). + +Treat this file and `GOVERNANCE.md` as authoritative for cross-cutting rules, and do not restate their rules elsewhere. A project's **project-specific conventions and public-API/behavioral contracts** (e.g. a "Library API Conventions" section) live in that project's own `AGENTS.md`, **not** in [`.github/copilot-instructions.md`](./.github/copilot-instructions.md), because that file targets GitHub Copilot / VS Code specifically, while this file and `GOVERNANCE.md` are the agent-agnostic ones every coding agent is directed to read, so any rule a reviewer must honor has to live in one of those two files to be provider-independent. + +## Fleet Bootstrap + +This repository is governed by a shared template, and the canonical rules, machine-readable spec, and procedures live in `github.com/ptr727/ProjectTemplate`, the repository these rules call the hub. Fetch that repository before acting on anything about conformance, carried content, repository settings, or standing a repository up, because a carried copy here can be stale or absent and the hub is the only authority on what this repository is supposed to hold. This section is byte-locked across every repository in the fleet, so it reads identically wherever it is found, and it is the entry point whenever nothing else present says where the rules are. + +Route by what this repository currently holds rather than by what it is expected to hold, since the two differ exactly when this section matters most. + +```mermaid +flowchart TD + state["what does this repository currently hold?"] + state -->|"no repo, or a local tree with no remote"| standup["hub STANDUP.md, from section 0"] + state -->|"no carried instruction set, or a partial one"| standup2["hub STANDUP.md sections 1A, 2"] + state -->|"instruction set present, current or stale"| resync["hub RESYNC.md"] + state -->|"believes it is conformant"| resync2["hub RESYNC.md, run the audit anyway"] +``` + +- **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. + +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. + +### Session Scope + +- **One deliverable, one session.** A session covers one branch and one deliverable, and ends when that work merges. A multi-step task is one deliverable and stays in one session. Two unrelated tasks are two sessions even when they run back to back. +- **End a session at any of these, without being asked:** the branch changes, the pull request merges, or the next task is unrelated to the last. A review round is none of them. A loop still producing findings is the deliverable in progress, and a round count is not a reason to leave one open. +- **Hand off in a file, never in context.** Close a session by writing at most 2 KB to a scratch file: branch, pull request link, what is done, the next command. A summary held in context is re-billed until the session ends, and a summary on disk is read once by whoever needs it. +- **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. + +### Reading + +- **Map a large file, then read one range.** For anything over about 200 lines, list the headings with `grep -n '^## '` first and read only the range the task needs. Read the section, not the file that contains it. +- **Prefer an in-place edit to a whole-file rewrite.** Rewriting a file bills its full content again on top of what the read already cost. + +### Commands + +- **Bound output at the source.** Write every command so its output is the answer, not the haystack: a `--jq` projection on an API call, a count or files-only flag on a search, a summary flag on a diff, an explicit cap on anything unbounded. A command whose output you then skim is a command that should have been narrower. +- **Keep a long query in a file, not in the command.** A heredoc re-typed on every call costs its own length in context each time, often more than the answer it retrieves. + +### Delegation + +- **Delegate exploration, keep judgment.** A subagent starts from an empty context and returns only its conclusion, so a wide search, a multi-file audit, or a "which of these is affected" question costs a fraction of the same work inline. Delegate when the finding compresses to a short answer, and stay inline when the intermediate detail drives the next edit. +- **Match the model tier to the judgment, not to the diff size.** Mechanical work (a known-shape edit repeated across files, an extraction, a status check, a lint fix) runs on the cheapest model that does it correctly, at the lowest reasoning effort that holds. State the tier in the delegation itself rather than accepting the default. A change to a gate, a ruleset, a release condition, or a carried governance section is a design change however small it looks. +- **Never tier down the seat holding the judgment.** Governance wording, spec logic, rulesets, repository visibility, and the decision to decline a review finding are fleet-wide and durable when wrong. Tier the subagents, not the main thread. +- **Brief a subagent so it never needs a governance file.** A subagent inherits no context, so anything it must honor has to be in its prompt. Reading `GOVERNANCE.md` to find out costs it the same tokens the main thread would have paid. Brief on this shape: + +```text +Task: <the one question or edit, stated so the answer compresses> +Paths: <exact files or globs - never "find the relevant files"> +Rules that bind this task: <the specific rules, quoted, not a pointer to a doc> +Return: <the shape of the answer - a list, a diff, a yes/no with evidence> +Bounds: <what not to touch, and what to do when a rule looks incomplete> +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. +- **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. + +## Where the Rules Live + +Every rule below is a level-two section of [`GOVERNANCE.md`](./GOVERNANCE.md). Read the section the task needs. + +| 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 | +| 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 | +| 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` | +| 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 | +| Requesting, answering, or closing a review | `PR Review Etiquette`, packaged as the `pr-review-conduct` Skill | +| Reporting progress or asking the user something | `Communicating with the User`, surfaced at its decision moment by the `agent-conduct` Skill, and the section keeps the full rules | +| Editing a workflow YAML file | `Workflow YAML Conventions`, surfaced with the full `WORKFLOW.md` contract by the `workflow-ci-contract` Skill, and this section and `WORKFLOW.md` keep the full rules | +| Choosing an OS, runtime, or toolchain target | `Supported Development Platforms` | +| The devcontainer | `Devcontainer` | +| Editor settings and tasks | `Editor and Tasks` | +| The About panel, description, or repo toggles | `Repository Details` | +| Where a file belongs in the tree | `Repository Layout` | + +A row above with no Skill annotation is doc-only by decision, not by omission. A Skill surfaces rules at a trigger moment, and each unannotated section either binds always or carries no moment narrower than reading it: `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 must bind even when no Skill fires (the `gh-write-guard` hook and the host-wide instruction blocks the agent-safety installer maintains are their enforcement layer, and the one moment in the boundaries section narrow enough to surface, isolating into a worktree at task start, gets the `repo-worktree` Skill on top of that law rather than instead of it), 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. diff --git a/AUDIT.md b/AUDIT.md index 87383408..a3d854fa 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -1,228 +1,228 @@ -# AUDIT.md - -How an agent audits a repository against the fleet ground truth in this repo and reports drift. This is the procedure. The ground truth it checks against is [`registry/repos.json`][repos], the [`spec/`][spec] manifests, [`repo-config/`][repo-config], and the prose authorities ([`GOVERNANCE.md`][governance], [`CODESTYLE.md`][codestyle], [`WORKFLOW.md`][workflow]). The audit is read-only: it produces a report under [`reports/`][reports], never edits the target repo. - -The verdict vocabulary is [`WORKFLOW.md`][workflow]'s: **operational / not operational**, **N/A**, -**defect**, and the applicable/absent rule. Do not invent a parallel scheme. - -**This file measures. It does not decide the order a finding is applied in.** Section 10 states that converging is a separate phase and how a fix ships, and [`RESYNC.md`][resync] is that phase for a repository that is already stood up and has fallen behind, sequencing the remedies so the rules land before the files they govern and a deletion lands before the re-vendor that would otherwise refresh it. - -```mermaid -flowchart TD - s0["0: has the repo been stood up? if not, STANDUP.md"] --> s1["1: scope, ground-truth branch (main)"] - s1 --> s2["2: resolve the repo's type(s)"] - s2 --> s3["3: applicability gate, per check"] - s3 --> s4["4: per-dimension checks, letter and intent"] - s4 --> s5["5: assert Actions implement WORKFLOW.md"] - s5 --> s6["6: validate settings, rulesets, secrets"] - s6 --> s7["7: verdict model"] - s7 -->|"every applicable check passes"| operational["operational"] - s7 -->|"a letter miss, intent satisfied"| drift["drift finding"] - s7 -->|"letter and intent both miss"| defect["defect: not operational"] - s7 --> s8["8: report, under reports/"] -``` - -## 0. When to Run and What "Done" Means - -**Start here only if the repo already carries its instruction set.** This file measures a repo against the fleet ground truth, and measuring assumes the thing being measured arrived. A repo holding no carried files, or a partial set, has a baseline that never arrived rather than drift to report, so it goes to [`STANDUP.md`][standup] sections 1A and 2 first and comes back here afterwards. The `AGENTS.md` "Fleet Bootstrap" section states that routing in the repo itself, byte-locked, so an agent finds it without knowing this file exists. Running the audit against a repo with nothing to audit produces a report that is all absences, which reads as a catastrophic result rather than as a repo that was never stood up. - -This audit is not occasional. Run it whenever you **create, adopt, or materially change** a fleet repo, and on demand for any known repo: - -- **Onboarding a repo is complete only when it either passes this audit** (operational on every applicable check) **or carries a committed `reports/<repo>/audit.md` plus a tracking issue** enumerating every residual delta. A repo that is partially set up but never audited is itself a **defect**, the exact state this process prevents. The create-to-conformance counterpart is [`STANDUP.md`][standup]. Because both read the same manifests, a repo stood up by that file passes this audit by construction. -- **Touching a repo** (any conformance-affecting change) ends by re-running the applicable checks and **reconciling the registry entry to reality**: `status`, `types`, `releaseTrigger`, `workflowModel`, `driftNotes`. The registry records reality, not intent. [`spec/validate.py`][validate] proves the catalog is self-consistent, not that it matches the live repo. Closing that gap is this audit's job. The deterministic subset (settings, rulesets, secret names, file presence, per-scope Markdown section presence, workflow interface conformance, verbatim content, hub-hosted files a repo carries, branch facts) is mechanized in [`spec/audit.py`][audit-runner]: owner-initiated, run on demand when onboarding a repo, on suspected drift, or before fleet-wide changes. A required section missing from a carried Markdown file is a **drift finding**, not a letter, because a heading rename reads as missing and equivalence is judged by hand. A carried `interface` workflow (spec/fidelity-model.md) is checked by name and wiring (required jobs, the ruleset-bound check name, the artifact-name handoff, and the forbidden `artifact-ids:` fork), all at **drift**, since the body is owned and a rename is a hint to verify. A carried `verbatim` unit, whether a whole file (`.markdownlint-cli2.jsonc`) or a canonical workflow job region (the `github-release` job), is content-hashed against the hub's canonical after line-ending normalization. A mismatch is classified **stale** (matches a past hub revision, re-vendor) or **modified** (matches none, the repo changed fixed content), both at **drift**, since equivalence is intent-governed and a byte diff is a hint to review. A carried `intent` unit gets one advisory beyond presence, a last-modified comparison: a hub canonical changing after the copy's own last commit marks the copy as possibly trailing, at **drift**, a hint rather than proof, since a copy touched without reconciling reads current and content is never judged. - -**Verify the host before running any hub tool.** The tools carry version floors, and a host below one answers `--version`, looks healthy, and produces a wrong answer, so a clean audit run from a broken host is a clean-looking result rather than a result. - -```shell -python3 scripts/host_gate.py --repo <path-to-target-checkout> # run from a hub checkout, floors from spec/host-tools.json -``` - -Pass `--repo`, since the gate reads the target's own `host-tools.json` relative to it and defaults to the working directory. Omitting it does not read the target's declaration at all, so every floor that repo adds goes unapplied, and the run reports nothing about the omission. A finding is a **host** misconfiguration rather than a repo one, and [`docs/host-setup.md`][host-setup] is the contract it checks. - -## 1. Scope and Ground-Truth Branch - -Audit one repository at a time. Read the target's **`main` branch** as ground truth: `main` is the released, authoritative state. Read `develop` only to detect divergence. A stale or diverged `develop` (behind `main`, or diverged) is reported as a **drift finding**, never audited as the truth. Do not treat a `develop`-only file as present if it is absent on `main`. - -This holds for **both workflow models**. An `operational` repo commits directly to `develop`, but its ground truth is still `main`, the promoted and gated snapshot the promotion PR blesses. `develop` there is mid-flight by design (ungated direct pushes), so auditing it would measure work in progress: conformance scaffolding that has landed on `develop` but is not yet promoted is *un-promoted work*, not a conformance defect, and it counts when it reaches `main`. A registry `groundTruthBranch` naming `develop` therefore contradicts this section, for either model. - -## 2. Resolve the Repo's Type(s) - -Look up the repo in [`registry/repos.json`][repos] and read its `types[]`. If the entry is `classificationPending` (a backlog repo), classify it from the tree and propose a registry update: - -- `*.csproj` / `*.slnx` -> `csharp`, a `dotnet nuget push` workflow -> `nuget`, a `System.CommandLine` console -> `console`. -- `pyproject.toml` / `setup.py` -> `python`, a `pypa/gh-action-pypi-publish` workflow -> `pypi`. -- `Dockerfile` + a docker build/push workflow -> `docker`, an `upstream-version.json` tracker -> `upstream-wrapper`. -- `custom_components/*/manifest.json` + `hacs.json` -> `homeassistant`, a codegen workflow -> `codegen`, no `build-*` task -> `source-only`, governance-only -> `docs`. -- `hugo.yaml` / `hugo.toml` / `config/_default/hugo.yaml` -> `hugo`. A repo may carry it alongside `source-only`, since a site deploy leaf is not a `build-*` task and both declarations stay true. - -## 3. Applicability Gate - -Reuse [`WORKFLOW.md`][workflow] section 1: a check that governs a construct the repo does not contain is **N/A**. Record it as N/A and **exclude it from the verdict**. N/A is never a defect. A Docker check on a repo with no image, a NuGet check on a Python package, and the artifact-lifecycle clauses on a source-only repo are all N/A. - -Which carried files and sections a repo is expected to have is decided by its scope selectors (its type(s) plus workflow model, release trigger, and consumer model). The scope model and the `appliesTo` selector vocabulary are defined in [`spec/scope-model.md`][scope-model]. - -## 4. Per-Dimension Checks (Letter and Intent) - -For each applicable type in [`spec/project-types.json`][project-types] and every cross-cutting dimension, evaluate each check at its stated verdict tier: - -**Every check under a project type is judged by hand. The cross-cutting dimensions are only partly mechanized, and the line between the two halves is not where a reader assumes.** [`spec/audit.py`][audit-runner] evaluates **no** check belonging to a type in `spec/project-types.json`, and it reads that file for one purpose only, to resolve the id a registry `driftNote` names (section 8) against the catalog and against the repo's declared types. Resolving an id is not running the check it names. What the runner does mechanize is the deterministic subset in section 0, and that subset lands on several `crossCutting` checks without being organized by them: branch protection and the ruleset diffs, secret names, Dependabot ecosystems, the cspell single source, section presence, and `driftNotes` freshness. So read a clean run precisely. It is evidence for that subset, it is **no** evidence for any of a type's checks, and it is partial evidence across the cross-cutting dimensions. The three are easy to conflate, because adding a check under a type changes what an auditor must judge and changes no tool's output, so the check reports nothing until someone evaluates it, and silence from a tool that was never looking reads exactly like a pass. Cite the `file:line` each check was judged against, since that citation is the only durable record that the judgment happened. - -- **letter** - the exact file, section, config, or construct is present. -- **intent** - an equivalent outcome holds even if the form differs. - -A check with `intentRef`/`workflowRef` points at the prose section that owns the rationale, so read it to judge intent. The dimensions: - -- **csharp** - `.editorconfig` carries the shared `[*.cs]` rule block (letter), and analyzer severities are enforced, not relaxed (intent). -- **nuget** - publish uses OIDC Trusted Publishing with no `NUGET_API_KEY` (letter+intent), and the push carries `--skip-duplicate`. -- **pypi** - OIDC publish job carrying `environment: pypi`, `id-token: write` and `skip-existing: true`, with no stored token. -- **python** - ruff and pyright present (intent), canonical in `pyproject.toml` (letter), and a standalone `.ruff.toml` / `pyrightconfig.json` is a drift finding. -- **console** - smoke runtime matrix is a strict subset, and per-runtime outputs aggregate to one `release-asset-*`, gated `!smoke`. -- **docker** - registry layer cache (`buildcache-<branch>`, never `type=gha`), the size-limited Docker Hub README is published via the docker-readme task, and the image always re-pushes on publish. -- **hugo** - the build fails on a generator warning, the URL-parity gate asserts a length floor before comparing, the rendered output is untracked, the generator is pinned by version and checksum and declared once, a vendored tree records its upstream ref, and the deploy asserts what the host serves (the release id and the environment). Retention is bounded by a declared count with one side recorded as owning the prune, which is the deploy where its credential can observe the destination and the host where that credential is confined write-only, so grade which shape the repo uses rather than looking for a prune step. Deploy credentials are per-environment, which `spec/secrets.json` cannot express, so a clean **repo-setup** verdict says nothing about whether the environments are configured. -- **branch-model** - `main` and `develop` both exist and are protected, and the live rulesets match [`repo-config/*.json`][repo-config] by normalized diff (below). -- **carried-scope** - the repo carries no file the hub hosts rather than carries. The set is derived, not listed: the hub's git-tracked paths minus the [`spec/files.json`][files] baseline, so a file dropped from the manifest starts being reported on the next run with no retirement list to remember to edit. The remedy is the opposite of every other file finding, a **deletion**, since the repo reaches the hub's copy per [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling]. The match is on path alone, so a hit is a candidate and not a verdict: a repo's own content at a path the hub also uses matches while carrying nothing of the hub's, which the first fleet run showed twice, a KiCad tooling doc at `scripts/README.md` and per-repo formatting hooks at `.husky/pre-commit`. A [`spec/divergences.json`][divergences] `gaps` disposition decides which case a hit is, so only `retire` asserts a deletion, `accepted` closes a collision or a repo-owned file, and an untriaged hit is read before it is acted on. -- **repo-setup** - every required secret for the repo's publish mechanisms is configured, and no forbidden secret is present (per [`spec/secrets.json`][secrets]). -- **linter-parity** - one config per linter (`.markdownlint-cli2.jsonc`, `cspell.json`, ruff/pyright, editorconfig/csharpier, actionlint) drives the editor extension, the CLI, and CI, and CI runs each. -- **recurring-violations** - comments concise and non-narrative, ASCII only (no em-dash, no smart quotes), US spelling, line endings per `.editorconfig`. These are frequent regressions, so this dimension is high priority and always runs, and each check is grep-able (see below). -- **readme-structure** - the README follows [`spec/readme-structure.md`][readme-structure] (applicable sections, in order). Mechanically checked against the declared model in [`spec/readme-sections.json`][readme-sections]: required sections present, declared sections in their relative order, `License` last, the shields each deliverable implies, the license shield in the closing License section, and the tagline and its mirrors. A heading the model does not name is dropped before the order comparison, so a repo-specific section is never a finding. - -## 5. Assert the Actions Implement WORKFLOW.md - -Run [`WORKFLOW.md`][workflow]'s methodology against the repo's **own** Actions: the 5A static audit (structural facts per applicable D-guarantee, each with a `file:line` citation) and the 5B trace scenarios (predicted run/skip + version + release + artifact-end-state vs expected). The contract in WORKFLOW.md section 4 is satisfied by **outcome**, not by matching the catalog snippets in [`catalog/snippets/workflows/`][workflows] byte for byte. Those are the reference implementation, not required bytes. - -## 6. Validate Settings, Rulesets, and Secrets - -- **General settings** - diff the live repository settings against [`repo-config/settings.json`][repo-config-settings], and confirm the two state-dependent settings: `has_discussions` follows visibility (public on / private off) and `default_branch` is `main`. - - ```sh - live=$(gh api "repos/<owner>/<repo>" --jq '{has_wiki,has_projects,allow_merge_commit,allow_squash_merge,allow_rebase_merge,allow_auto_merge,allow_update_branch,delete_branch_on_merge}') - diff <(jq -S . repo-config/settings.json) <(jq -S . <<<"$live") \ - && echo "settings: in sync" || echo "settings: DRIFT" - ``` - -- **Rulesets** - diff each live ruleset against the committed expected payload with a normalized comparison (sort the order-insensitive `rules[]` on each rule's whole content before diffing, so a reordered but equivalent ruleset does not read as drift). The compared subset is `name`, `target`, `enforcement`, `conditions` and `rules`, and `bypass_actors` sits deliberately outside it, which is the same subset and the same sort key [`spec/audit.py`][audit-runner] uses. Who may bypass a ruleset is a per-repository human decision taken in the UI, no payload declares one, and [`repo-config/configure.sh`][repo-config] treats it that way in both modes, writing the live list back unchanged on `apply` and reporting it without asserting on `check`. Comparing it here would contradict that and report a ruleset finding against every repository that has any bypass actor, which is the field's normal state rather than a deviation: - - ```sh - # bypass_actors stays outside the projection, since no payload declares one and jq cannot sort the null that leaves. - # Rules sort on each rule's whole content, matching the key normalize_ruleset in audit.py sorts by. - # Sorting on .type alone leaves two rules of one type in input order, so a reordered pair would read as drift. - # canon sorts keys at every depth before serializing, because the committed payload is written key-sorted and the API returns its own order, so a bare tojson gives the same rule two different sort keys. - # It recurses rather than calling walk/1, which the declared floor does make available, because the recursion costs nothing and compiles below the floor as well. - # A host on jq 1.5 would not degrade on walk, it would fail to compile the filter and report drift on every ruleset it never compared, which is what repo-config/configure.sh defines its own recursion to avoid. - canon='def canon: . as $in | if type == "object" then reduce (keys_unsorted|sort)[] as $k ({}; . + { ($k): ($in[$k]|canon) }) elif type == "array" then map(canon) else . end;' - norm="$canon"'{name,target,enforcement,conditions,rules} | .rules|=sort_by(canon|tojson)' - # Model-aware expected payload: an operational repo's develop ruleset diffs against - # operational/develop.json (registry workflowModel; the same selection audit.py makes). - model=$(jq -r --arg n "<repo>" '(.repos[] | select(.name==$n) | .workflowModel) // .defaults.workflowModel // "release"' registry/repos.json) - # Paginate so later-page rulesets count: --paginate with --jq '.[]' emits one JSON object per ruleset - # across all pages; jq -s re-assembles them into the single array the selections below expect. - rulesets=$(gh api --paginate "repos/<owner>/<repo>/rulesets" --jq '.[]' | jq -s '.') - for b in develop main; do - file="repo-config/$b.json" - [ "$b" = "develop" ] && [ "$model" = "operational" ] && file="repo-config/operational/develop.json" - # Exactly one ruleset per name: zero or duplicates is itself a finding - report it, never diff a guess. - count=$(jq --arg n "$b" '[.[] | select(.name==$n)] | length' <<<"$rulesets") - [ "$count" -eq 1 ] || { echo "$b: expected exactly 1 ruleset, found $count (defect/drift)"; continue; } - id=$(jq --arg n "$b" '.[] | select(.name==$n) | .id' <<<"$rulesets") - diff <(jq -S "$norm" "$file") \ - <(gh api "repos/<owner>/<repo>/rulesets/$id" --jq '{name,target,enforcement,conditions,rules}' | jq -S "$norm") \ - && echo "$b: in sync" || echo "$b: DRIFT" - done - ``` - -- **Secrets** - confirm each required secret exists (name only, not the values). Check the Actions store and, where the mechanism needs it (Docker Hub, codegen App), the Dependabot store too. - -- **Dependabot ecosystem coverage** - for each ecosystem the repo's tree implies, `.github/dependabot.yml` must declare it: `github-actions` when `.github/workflows/` is present (its workflows reference actions, and otherwise those versions go stale and a stood-up merge-bot has no action-update PRs to auto-merge), and `devcontainers` when a `.devcontainer` is present. The mechanical check (`spec/audit.py`) asserts each implied ecosystem's **presence**. A tree-implied ecosystem declared nowhere is a **drift finding** (the file exists, so its absence would instead be a file-presence letter). Then confirm **by inspection** that each declared ecosystem **dual-targets `main` + `develop`** per the [Branching Model][governance-branching-model], since the regex below cannot pair an ecosystem with its `target-branch`. Language ecosystems (`nuget`/`uv`/`npm`) are directory-scoped and audited by inspection too. - - ```sh - # Anchor to the line start (optional list dash) so a commented-out '# package-ecosystem:' is not counted. - decl=$(gh api "repos/<owner>/<repo>/contents/.github/dependabot.yml?ref=<ground>" --jq '.content' | base64 -d | grep -oE '^[[:space:]]*-?[[:space:]]*package-ecosystem:[[:space:]]*"?[a-z-]+' | grep -oE '[a-z-]+$' | sort -u) - has() { gh api "repos/<owner>/<repo>/contents/$1?ref=<ground>" >/dev/null 2>&1; } - has .github/workflows && { grep -qx github-actions <<<"$decl" && echo "github-actions: present" || echo "github-actions: MISSING (workflows present)"; } - has .devcontainer && { grep -qx devcontainers <<<"$decl" && echo "devcontainers: present" || echo "devcontainers: MISSING (.devcontainer present)"; } - # then read dependabot.yml and confirm each present ecosystem has both a main and a develop target-branch entry - ``` - -## 7. Verdict Model - -Per dimension, record `operational | not-operational | N/A`, each with a letter verdict and an intent verdict: - -- letter miss but intent satisfied -> **drift finding** (equivalent outcome in a non-standard form, worth fixing, not a break). -- letter and intent both miss -> **defect** (not operational). - -A repo is **operational** only if every applicable check passes. A single applicable defect makes it not operational, regardless of how clean the rest looks. N/A items are excluded, never counted as failures. - -## 8. Report - -Write `reports/<repo>/audit.md` from [`reports/_template.md`][template]: a dimension x {letter, intent, verdict, evidence} table with `file:line` citations (WORKFLOW.md 5A style), a drift section, and a list of proposed registry/spec updates (e.g. a resolved `classificationPending`). Rank findings most severe first. - -**The hub authors the report, and a downstream repo does not open a pull request against the hub to write its own.** `reports/` is the hub's evidence that it audited a repo, so a report written by the repo being audited is a claim rather than evidence, and the hub cannot adopt one without checking it. Checking the judgment dimensions **is** the audit, since confirming a verdict like "analyzers enforced" means reading the same files the audit reads, so a submitted report saves only the writing up and not the work. A submitted report is also stale by construction, because it is a snapshot of one hub revision arriving at a later one, and its claims then have to be reconciled against findings that did not exist when it was written. - -What a downstream repo does instead is unchanged and is where its context is worth most: it **files findings about the hub as issues**, which is the opposite of self-certification and is how several hub defects have been found, and it **applies fixes to its own repo** per section 10. Any pull request it does open against the hub follows the same branching model as every other, targeting `develop` and never `main`. - -**Findings are a point-in-time snapshot. Stamp them and re-verify before acting.** [`spec/audit.py`][audit-runner] prints a run stamp (`audit run <UTC> | hub <sha>`) and, per repo, the exact commit it read (`@ <branch>@<sha>`). Anything derived from a run (a report, and especially an **onboarding or conformance issue**) quotes that stamp, so a reader can tell whether it still applies. A convergence issue is generated from the audit, never composed by hand: `spec/audit.py --issue <repo>` emits a ready-to-file title and body from that repo's live findings (grouped into must-fix, converge, and could-not-verify), so the issue content cannot drift from what the audit actually found and regenerates as the repo changes. - -**Verify a convergence before it is promoted with `--branch`.** `spec/audit.py --branch <ref> <repo>` reads that ref instead of the repo's registry `groundTruthBranch`, so a repo can audit its own `develop` while the work is still in flight rather than discovering the gaps after `main` has moved. The registry is not edited, the run is still read-only, and the run stamp names the override so a finding cannot be mistaken for one against ground truth. A ref that does not resolve is a single error naming it, never a baseline's worth of file-absent letters. - -**Re-running the audit needs a full hub clone with git history.** The verbatim stale-vs-modified classification walks the canonical's history (`git log` / `git show` from the hub root), so a shallow clone or a files-only checkout cannot answer "matches a past hub revision" and those findings are unreliable there. A downstream agent verifying one finding without the full history can instead compare against the current hub canonical on `main` (the whole file for a file-level unit, or the named `## heading` block for a verbatim section), which decides current-match but not stale-vs-modified. An agent picking up such an issue **re-runs the audit first and acts on the live result, not the pasted findings**: a repo moves between filing and pickup, so a stale block leads an agent to "fix" what is already fixed (re-requesting secrets that exist, attempting a no-op forward-sync). State the findings as evidence for *why* the issue was filed, never as the current state. - -**Reconcile `driftNotes` in the same pass.** A registry `driftNote` records a *current* deviation from the baseline. Once the deviation is resolved the note is deleted, not left describing finished work, since hand-maintained prose drifts silently otherwise. `spec/audit.py` flags two shapes of note, neither of them gated on the rest of the audit being clean. A note asserting outstanding work in prose ("pending", "not yet", "missing", "behind", ...) is contradicted outright by a clean audit, and where findings are open it is raised as a question of which one it means, because gating the check on a clean audit meant one standing finding a repo could not clear exempted its whole note list, and the repo carrying open findings is where a stale note is most likely. A note naming the check that would retire it, as an id in parentheses and matched with them (`(hugo.generator.pinned)`, the bare id is not detected), is the mechanically checkable shape and is surfaced on **every** run: the audit resolves the id against the catalog and confirms the repo declares its type, then hands the check itself to the auditor, since section 4 above is judged by hand. **So a note naming a check id is retired by a person, not by a run.** Write it that way anyway. The id says exactly what would close the note, and the surfaced finding puts that decision in front of whoever runs the audit rather than leaving the note to sit until someone rereads it. - -## 9. Escalate - -Surface spec questions rather than resolving them silently, for example the Python config-placement canonicalization, or a new construct no type covers. A repeated letter miss that many repos share is a signal the spec (not each repo) needs adjusting, so raise it. - -```mermaid -flowchart LR - finding["a finding from 7: Verdict Model"] -->|"the same miss, many repos"| s9["9: Escalate, fix the spec instead"] - finding -->|"one repo"| s10["10: Converge, branch + fix + PR"] - s10 --> review["review loop to green"] - review --> merge["maintainer merges"] - merge --> reaudit["re-audit, commit the report"] - s9 --> reaudit -``` - -## 10. Converge: Apply the Fixes - -Sections 1-9 (the audit and its report) are **read-only** and never touch the target. **Converging** is the separate follow-on phase: the drift the report found is **resolved by applying fixes to the target repo**, not left as a report. The convergence loop: - -- **Apply via a pull request on the target repo.** Branch from the target's `develop` (or `main` for a `main`-only repo), make the fix, and open a PR. Never push a fix directly to a protected branch, and never hand-edit a target outside a PR. -- **Drive the PR's Copilot review to green** - the same loop this repo runs (see [GOVERNANCE.md "PR Review Etiquette"][governance-pr-review-etiquette] and the [Copilot review runbook][copilot-runbook] in `.github/copilot-instructions.md`): request review on every push, address and resolve every thread, and confirm the review covers the head SHA. -- **Merge only with explicit maintainer approval.** The agent drives to green and stops. The maintainer merges. -- **One focused PR per drift class**, cross-referencing the audit finding. A sprawling all-drifts PR draws many review rounds and never feels done. -- **A `hub-only:` finding converges by deleting the file, not by updating it.** It is the one class where the fix removes content, so it is easy to convert into a re-vendor by reflex and end up refreshing a copy that should not exist. Delete the repo's copy and reach the hub's per [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling]. Confirm the disposition is `retire` before deleting anything: an untriaged hit may be the repo's own content at a shared path, and deleting that destroys work the hub never owned. -- **Any deletion sweeps the inbound references to the path, and the sweep is part of the deletion rather than follow-up.** This governs every removal and not only a `hub-only:` one, because nothing about it depends on who owned the file: the removal is one edit and finishing the job usually takes several more, so grep the path tree-wide first and read every hit. Then read the files whose job is to say what the repo holds, since a grep for the path finds uses of the file and misses descriptions of it: `GOVERNANCE.md` "Repository Layout" is the one that has gone stale this way, calling a deleted script "the apply script", which names no path and survives every search for one. A link whose target has an equivalent elsewhere is re-pointed at it, the hub's copy being that equivalent for a hub-hosted file, a **runnable command** citing the path is rewritten to the invocation that still works, and a mention with no equivalent anywhere is removed along with its reference definition, which [GOVERNANCE.md "Documentation Style Conventions"][governance-documentation-style] requires because an orphaned definition fails the no-unused-defs rule. Measured rather than hypothetical, in both directions: retiring `configure.sh` makes five lines of one repo's carried `repo-config/README.md` wrong, two of them commands a reader would run, and deleting a repo-owned nested `AGENTS.md` took three edits across two files, where removing the inline link alone tripped `MD053` on the definition it orphaned and would have failed CI rather than merely breaking prose. -- **Fix systemic drift in the hub, not per repo.** When many repos share a drift, fix the spec/rule (or add a machine check) here and let a re-audit re-flag it, rather than hand-patching each repo for the shared cause. - -The convergence model: the hub audits and the agent **applies** the fixes via target PRs, and the maintainer gates every merge. It supersedes any "the hub only reports; downstream operators apply by hand" framing. - -<!-- Workflow --> - -<!-- Repo --> -[audit-runner]: ./spec/audit.py -[codestyle]: ./CODESTYLE.md -[copilot-runbook]: ./.github/copilot-instructions.md -[divergences]: ./spec/divergences.json -[files]: ./spec/files.json -[governance]: ./GOVERNANCE.md -[governance-branching-model]: ./GOVERNANCE.md#branching-model -[governance-documentation-style]: ./GOVERNANCE.md#documentation-style-conventions -[governance-hub-hosted-tooling]: ./GOVERNANCE.md#hub-hosted-tooling -[governance-pr-review-etiquette]: ./GOVERNANCE.md#pr-review-etiquette -[host-setup]: ./docs/host-setup.md -[project-types]: ./spec/project-types.json -[readme-sections]: ./spec/readme-sections.json -[readme-structure]: ./spec/readme-structure.md -[repo-config]: ./repo-config/ -[repo-config-settings]: ./repo-config/settings.json -[reports]: ./reports/ -[repos]: ./registry/repos.json -[resync]: ./RESYNC.md -[scope-model]: ./spec/scope-model.md -[secrets]: ./spec/secrets.json -[spec]: ./spec/ -[standup]: ./STANDUP.md -[template]: ./reports/_template.md -[validate]: ./spec/validate.py -[workflow]: ./WORKFLOW.md -[workflows]: ./catalog/snippets/workflows/ +# AUDIT.md + +How an agent audits a repository against the fleet ground truth in this repo and reports drift. This is the procedure. The ground truth it checks against is [`registry/repos.json`][repos], the [`spec/`][spec] manifests, [`repo-config/`][repo-config], and the prose authorities ([`GOVERNANCE.md`][governance], [`CODESTYLE.md`][codestyle], [`WORKFLOW.md`][workflow]). The audit is read-only: it produces a report under [`reports/`][reports], never edits the target repo. + +The verdict vocabulary is [`WORKFLOW.md`][workflow]'s: **operational / not operational**, **N/A**, +**defect**, and the applicable/absent rule. Do not invent a parallel scheme. + +**This file measures. It does not decide the order a finding is applied in.** Section 10 states that converging is a separate phase and how a fix ships, and [`RESYNC.md`][resync] is that phase for a repository that is already stood up and has fallen behind, sequencing the remedies so the rules land before the files they govern and a deletion lands before the re-vendor that would otherwise refresh it. + +```mermaid +flowchart TD + s0["0: has the repo been stood up? if not, STANDUP.md"] --> s1["1: scope, ground-truth branch (main)"] + s1 --> s2["2: resolve the repo's type(s)"] + s2 --> s3["3: applicability gate, per check"] + s3 --> s4["4: per-dimension checks, letter and intent"] + s4 --> s5["5: assert Actions implement WORKFLOW.md"] + s5 --> s6["6: validate settings, rulesets, secrets"] + s6 --> s7["7: verdict model"] + s7 -->|"every applicable check passes"| operational["operational"] + s7 -->|"a letter miss, intent satisfied"| drift["drift finding"] + s7 -->|"letter and intent both miss"| defect["defect: not operational"] + s7 --> s8["8: report, under reports/"] +``` + +## 0. When to Run and What "Done" Means + +**Start here only if the repo already carries its instruction set.** This file measures a repo against the fleet ground truth, and measuring assumes the thing being measured arrived. A repo holding no carried files, or a partial set, has a baseline that never arrived rather than drift to report, so it goes to [`STANDUP.md`][standup] sections 1A and 2 first and comes back here afterwards. The `AGENTS.md` "Fleet Bootstrap" section states that routing in the repo itself, byte-locked, so an agent finds it without knowing this file exists. Running the audit against a repo with nothing to audit produces a report that is all absences, which reads as a catastrophic result rather than as a repo that was never stood up. + +This audit is not occasional. Run it whenever you **create, adopt, or materially change** a fleet repo, and on demand for any known repo: + +- **Onboarding a repo is complete only when it either passes this audit** (operational on every applicable check) **or carries a committed `reports/<repo>/audit.md` plus a tracking issue** enumerating every residual delta. A repo that is partially set up but never audited is itself a **defect**, the exact state this process prevents. The create-to-conformance counterpart is [`STANDUP.md`][standup]. Because both read the same manifests, a repo stood up by that file passes this audit by construction. +- **Touching a repo** (any conformance-affecting change) ends by re-running the applicable checks and **reconciling the registry entry to reality**: `status`, `types`, `releaseTrigger`, `workflowModel`, `driftNotes`. The registry records reality, not intent. [`spec/validate.py`][validate] proves the catalog is self-consistent, not that it matches the live repo. Closing that gap is this audit's job. The deterministic subset (settings, rulesets, secret names, file presence, per-scope Markdown section presence, workflow interface conformance, verbatim content, hub-hosted files a repo carries, branch facts) is mechanized in [`spec/audit.py`][audit-runner]: owner-initiated, run on demand when onboarding a repo, on suspected drift, or before fleet-wide changes. A required section missing from a carried Markdown file is a **drift finding**, not a letter, because a heading rename reads as missing and equivalence is judged by hand. A carried `interface` workflow (spec/fidelity-model.md) is checked by name and wiring (required jobs, the ruleset-bound check name, the artifact-name handoff, and the forbidden `artifact-ids:` fork), all at **drift**, since the body is owned and a rename is a hint to verify. A carried `verbatim` unit, whether a whole file (`.markdownlint-cli2.jsonc`) or a canonical workflow job region (the `github-release` job), is content-hashed against the hub's canonical after line-ending normalization. A mismatch is classified **stale** (matches a past hub revision, re-vendor) or **modified** (matches none, the repo changed fixed content), both at **drift**, since equivalence is intent-governed and a byte diff is a hint to review. A carried `intent` unit gets one advisory beyond presence, a last-modified comparison: a hub canonical changing after the copy's own last commit marks the copy as possibly trailing, at **drift**, a hint rather than proof, since a copy touched without reconciling reads current and content is never judged. + +**Verify the host before running any hub tool.** The tools carry version floors, and a host below one answers `--version`, looks healthy, and produces a wrong answer, so a clean audit run from a broken host is a clean-looking result rather than a result. + +```shell +python3 scripts/host_gate.py --repo <path-to-target-checkout> # run from a hub checkout, floors from spec/host-tools.json +``` + +Pass `--repo`, since the gate reads the target's own `host-tools.json` relative to it and defaults to the working directory. Omitting it does not read the target's declaration at all, so every floor that repo adds goes unapplied, and the run reports nothing about the omission. A finding is a **host** misconfiguration rather than a repo one, and [`docs/host-setup.md`][host-setup] is the contract it checks. + +## 1. Scope and Ground-Truth Branch + +Audit one repository at a time. Read the target's **`main` branch** as ground truth: `main` is the released, authoritative state. Read `develop` only to detect divergence. A stale or diverged `develop` (behind `main`, or diverged) is reported as a **drift finding**, never audited as the truth. Do not treat a `develop`-only file as present if it is absent on `main`. + +This holds for **both workflow models**. An `operational` repo commits directly to `develop`, but its ground truth is still `main`, the promoted and gated snapshot the promotion PR blesses. `develop` there is mid-flight by design (ungated direct pushes), so auditing it would measure work in progress: conformance scaffolding that has landed on `develop` but is not yet promoted is *un-promoted work*, not a conformance defect, and it counts when it reaches `main`. A registry `groundTruthBranch` naming `develop` therefore contradicts this section, for either model. + +## 2. Resolve the Repo's Type(s) + +Look up the repo in [`registry/repos.json`][repos] and read its `types[]`. If the entry is `classificationPending` (a backlog repo), classify it from the tree and propose a registry update: + +- `*.csproj` / `*.slnx` -> `csharp`, a `dotnet nuget push` workflow -> `nuget`, a `System.CommandLine` console -> `console`. +- `pyproject.toml` / `setup.py` -> `python`, a `pypa/gh-action-pypi-publish` workflow -> `pypi`. +- `Dockerfile` + a docker build/push workflow -> `docker`, an `upstream-version.json` tracker -> `upstream-wrapper`. +- `custom_components/*/manifest.json` + `hacs.json` -> `homeassistant`, a codegen workflow -> `codegen`, no `build-*` task -> `source-only`, governance-only -> `docs`. +- `hugo.yaml` / `hugo.toml` / `config/_default/hugo.yaml` -> `hugo`. A repo may carry it alongside `source-only`, since a site deploy leaf is not a `build-*` task and both declarations stay true. + +## 3. Applicability Gate + +Reuse [`WORKFLOW.md`][workflow] section 1: a check that governs a construct the repo does not contain is **N/A**. Record it as N/A and **exclude it from the verdict**. N/A is never a defect. A Docker check on a repo with no image, a NuGet check on a Python package, and the artifact-lifecycle clauses on a source-only repo are all N/A. + +Which carried files and sections a repo is expected to have is decided by its scope selectors (its type(s) plus workflow model, release trigger, and consumer model). The scope model and the `appliesTo` selector vocabulary are defined in [`spec/scope-model.md`][scope-model]. + +## 4. Per-Dimension Checks (Letter and Intent) + +For each applicable type in [`spec/project-types.json`][project-types] and every cross-cutting dimension, evaluate each check at its stated verdict tier: + +**Every check under a project type is judged by hand. The cross-cutting dimensions are only partly mechanized, and the line between the two halves is not where a reader assumes.** [`spec/audit.py`][audit-runner] evaluates **no** check belonging to a type in `spec/project-types.json`, and it reads that file for one purpose only, to resolve the id a registry `driftNote` names (section 8) against the catalog and against the repo's declared types. Resolving an id is not running the check it names. What the runner does mechanize is the deterministic subset in section 0, and that subset lands on several `crossCutting` checks without being organized by them: branch protection and the ruleset diffs, secret names, Dependabot ecosystems, the cspell single source, section presence, and `driftNotes` freshness. So read a clean run precisely. It is evidence for that subset, it is **no** evidence for any of a type's checks, and it is partial evidence across the cross-cutting dimensions. The three are easy to conflate, because adding a check under a type changes what an auditor must judge and changes no tool's output, so the check reports nothing until someone evaluates it, and silence from a tool that was never looking reads exactly like a pass. Cite the `file:line` each check was judged against, since that citation is the only durable record that the judgment happened. + +- **letter** - the exact file, section, config, or construct is present. +- **intent** - an equivalent outcome holds even if the form differs. + +A check with `intentRef`/`workflowRef` points at the prose section that owns the rationale, so read it to judge intent. The dimensions: + +- **csharp** - `.editorconfig` carries the shared `[*.cs]` rule block (letter), and analyzer severities are enforced, not relaxed (intent). +- **nuget** - publish uses OIDC Trusted Publishing with no `NUGET_API_KEY` (letter+intent), and the push carries `--skip-duplicate`. +- **pypi** - OIDC publish job carrying `environment: pypi`, `id-token: write` and `skip-existing: true`, with no stored token. +- **python** - ruff and pyright present (intent), canonical in `pyproject.toml` (letter), and a standalone `.ruff.toml` / `pyrightconfig.json` is a drift finding. +- **console** - smoke runtime matrix is a strict subset, and per-runtime outputs aggregate to one `release-asset-*`, gated `!smoke`. +- **docker** - registry layer cache (`buildcache-<branch>`, never `type=gha`), the size-limited Docker Hub README is published via the docker-readme task, and the image always re-pushes on publish. +- **hugo** - the build fails on a generator warning, the URL-parity gate asserts a length floor before comparing, the rendered output is untracked, the generator is pinned by version and checksum and declared once, a vendored tree records its upstream ref, and the deploy asserts what the host serves (the release id and the environment). Retention is bounded by a declared count with one side recorded as owning the prune, which is the deploy where its credential can observe the destination and the host where that credential is confined write-only, so grade which shape the repo uses rather than looking for a prune step. Deploy credentials are per-environment, which `spec/secrets.json` cannot express, so a clean **repo-setup** verdict says nothing about whether the environments are configured. +- **branch-model** - `main` and `develop` both exist and are protected, and the live rulesets match [`repo-config/*.json`][repo-config] by normalized diff (below). +- **carried-scope** - the repo carries no file the hub hosts rather than carries. The set is derived, not listed: the hub's git-tracked paths minus the [`spec/files.json`][files] baseline, so a file dropped from the manifest starts being reported on the next run with no retirement list to remember to edit. The remedy is the opposite of every other file finding, a **deletion**, since the repo reaches the hub's copy per [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling]. The match is on path alone, so a hit is a candidate and not a verdict: a repo's own content at a path the hub also uses matches while carrying nothing of the hub's, which the first fleet run showed twice, a KiCad tooling doc at `scripts/README.md` and per-repo formatting hooks at `.husky/pre-commit`. A [`spec/divergences.json`][divergences] `gaps` disposition decides which case a hit is, so only `retire` asserts a deletion, `accepted` closes a collision or a repo-owned file, and an untriaged hit is read before it is acted on. +- **repo-setup** - every required secret for the repo's publish mechanisms is configured, and no forbidden secret is present (per [`spec/secrets.json`][secrets]). +- **linter-parity** - one config per linter (`.markdownlint-cli2.jsonc`, `cspell.json`, ruff/pyright, editorconfig/csharpier, actionlint) drives the editor extension, the CLI, and CI, and CI runs each. +- **recurring-violations** - comments concise and non-narrative, ASCII only (no em-dash, no smart quotes), US spelling, line endings per `.editorconfig`. These are frequent regressions, so this dimension is high priority and always runs, and each check is grep-able (see below). +- **readme-structure** - the README follows [`spec/readme-structure.md`][readme-structure] (applicable sections, in order). Mechanically checked against the declared model in [`spec/readme-sections.json`][readme-sections]: required sections present, declared sections in their relative order, `License` last, the shields each deliverable implies, the license shield in the closing License section, and the tagline and its mirrors. A heading the model does not name is dropped before the order comparison, so a repo-specific section is never a finding. + +## 5. Assert the Actions Implement WORKFLOW.md + +Run [`WORKFLOW.md`][workflow]'s methodology against the repo's **own** Actions: the 5A static audit (structural facts per applicable D-guarantee, each with a `file:line` citation) and the 5B trace scenarios (predicted run/skip + version + release + artifact-end-state vs expected). The contract in WORKFLOW.md section 4 is satisfied by **outcome**, not by matching the catalog snippets in [`catalog/snippets/workflows/`][workflows] byte for byte. Those are the reference implementation, not required bytes. + +## 6. Validate Settings, Rulesets, and Secrets + +- **General settings** - diff the live repository settings against [`repo-config/settings.json`][repo-config-settings], and confirm the two state-dependent settings: `has_discussions` follows visibility (public on / private off) and `default_branch` is `main`. + + ```sh + live=$(gh api "repos/<owner>/<repo>" --jq '{has_wiki,has_projects,allow_merge_commit,allow_squash_merge,allow_rebase_merge,allow_auto_merge,allow_update_branch,delete_branch_on_merge}') + diff <(jq -S . repo-config/settings.json) <(jq -S . <<<"$live") \ + && echo "settings: in sync" || echo "settings: DRIFT" + ``` + +- **Rulesets** - diff each live ruleset against the committed expected payload with a normalized comparison (sort the order-insensitive `rules[]` on each rule's whole content before diffing, so a reordered but equivalent ruleset does not read as drift). The compared subset is `name`, `target`, `enforcement`, `conditions` and `rules`, and `bypass_actors` sits deliberately outside it, which is the same subset and the same sort key [`spec/audit.py`][audit-runner] uses. Who may bypass a ruleset is a per-repository human decision taken in the UI, no payload declares one, and [`repo-config/configure.sh`][repo-config] treats it that way in both modes, writing the live list back unchanged on `apply` and reporting it without asserting on `check`. Comparing it here would contradict that and report a ruleset finding against every repository that has any bypass actor, which is the field's normal state rather than a deviation: + + ```sh + # bypass_actors stays outside the projection, since no payload declares one and jq cannot sort the null that leaves. + # Rules sort on each rule's whole content, matching the key normalize_ruleset in audit.py sorts by. + # Sorting on .type alone leaves two rules of one type in input order, so a reordered pair would read as drift. + # canon sorts keys at every depth before serializing, because the committed payload is written key-sorted and the API returns its own order, so a bare tojson gives the same rule two different sort keys. + # It recurses rather than calling walk/1, which the declared floor does make available, because the recursion costs nothing and compiles below the floor as well. + # A host on jq 1.5 would not degrade on walk, it would fail to compile the filter and report drift on every ruleset it never compared, which is what repo-config/configure.sh defines its own recursion to avoid. + canon='def canon: . as $in | if type == "object" then reduce (keys_unsorted|sort)[] as $k ({}; . + { ($k): ($in[$k]|canon) }) elif type == "array" then map(canon) else . end;' + norm="$canon"'{name,target,enforcement,conditions,rules} | .rules|=sort_by(canon|tojson)' + # Model-aware expected payload: an operational repo's develop ruleset diffs against + # operational/develop.json (registry workflowModel; the same selection audit.py makes). + model=$(jq -r --arg n "<repo>" '(.repos[] | select(.name==$n) | .workflowModel) // .defaults.workflowModel // "release"' registry/repos.json) + # Paginate so later-page rulesets count: --paginate with --jq '.[]' emits one JSON object per ruleset + # across all pages; jq -s re-assembles them into the single array the selections below expect. + rulesets=$(gh api --paginate "repos/<owner>/<repo>/rulesets" --jq '.[]' | jq -s '.') + for b in develop main; do + file="repo-config/$b.json" + [ "$b" = "develop" ] && [ "$model" = "operational" ] && file="repo-config/operational/develop.json" + # Exactly one ruleset per name: zero or duplicates is itself a finding - report it, never diff a guess. + count=$(jq --arg n "$b" '[.[] | select(.name==$n)] | length' <<<"$rulesets") + [ "$count" -eq 1 ] || { echo "$b: expected exactly 1 ruleset, found $count (defect/drift)"; continue; } + id=$(jq --arg n "$b" '.[] | select(.name==$n) | .id' <<<"$rulesets") + diff <(jq -S "$norm" "$file") \ + <(gh api "repos/<owner>/<repo>/rulesets/$id" --jq '{name,target,enforcement,conditions,rules}' | jq -S "$norm") \ + && echo "$b: in sync" || echo "$b: DRIFT" + done + ``` + +- **Secrets** - confirm each required secret exists (name only, not the values). Check the Actions store and, where the mechanism needs it (Docker Hub, codegen App), the Dependabot store too. + +- **Dependabot ecosystem coverage** - for each ecosystem the repo's tree implies, `.github/dependabot.yml` must declare it: `github-actions` when `.github/workflows/` is present (its workflows reference actions, and otherwise those versions go stale and a stood-up merge-bot has no action-update PRs to auto-merge), and `devcontainers` when a `.devcontainer` is present. The mechanical check (`spec/audit.py`) asserts each implied ecosystem's **presence**. A tree-implied ecosystem declared nowhere is a **drift finding** (the file exists, so its absence would instead be a file-presence letter). Then confirm **by inspection** that each declared ecosystem **dual-targets `main` + `develop`** per the [Branching Model][governance-branching-model], since the regex below cannot pair an ecosystem with its `target-branch`. Language ecosystems (`nuget`/`uv`/`npm`) are directory-scoped and audited by inspection too. + + ```sh + # Anchor to the line start (optional list dash) so a commented-out '# package-ecosystem:' is not counted. + decl=$(gh api "repos/<owner>/<repo>/contents/.github/dependabot.yml?ref=<ground>" --jq '.content' | base64 -d | grep -oE '^[[:space:]]*-?[[:space:]]*package-ecosystem:[[:space:]]*"?[a-z-]+' | grep -oE '[a-z-]+$' | sort -u) + has() { gh api "repos/<owner>/<repo>/contents/$1?ref=<ground>" >/dev/null 2>&1; } + has .github/workflows && { grep -qx github-actions <<<"$decl" && echo "github-actions: present" || echo "github-actions: MISSING (workflows present)"; } + has .devcontainer && { grep -qx devcontainers <<<"$decl" && echo "devcontainers: present" || echo "devcontainers: MISSING (.devcontainer present)"; } + # then read dependabot.yml and confirm each present ecosystem has both a main and a develop target-branch entry + ``` + +## 7. Verdict Model + +Per dimension, record `operational | not-operational | N/A`, each with a letter verdict and an intent verdict: + +- letter miss but intent satisfied -> **drift finding** (equivalent outcome in a non-standard form, worth fixing, not a break). +- letter and intent both miss -> **defect** (not operational). + +A repo is **operational** only if every applicable check passes. A single applicable defect makes it not operational, regardless of how clean the rest looks. N/A items are excluded, never counted as failures. + +## 8. Report + +Write `reports/<repo>/audit.md` from [`reports/_template.md`][template]: a dimension x {letter, intent, verdict, evidence} table with `file:line` citations (WORKFLOW.md 5A style), a drift section, and a list of proposed registry/spec updates (e.g. a resolved `classificationPending`). Rank findings most severe first. + +**The hub authors the report, and a downstream repo does not open a pull request against the hub to write its own.** `reports/` is the hub's evidence that it audited a repo, so a report written by the repo being audited is a claim rather than evidence, and the hub cannot adopt one without checking it. Checking the judgment dimensions **is** the audit, since confirming a verdict like "analyzers enforced" means reading the same files the audit reads, so a submitted report saves only the writing up and not the work. A submitted report is also stale by construction, because it is a snapshot of one hub revision arriving at a later one, and its claims then have to be reconciled against findings that did not exist when it was written. + +What a downstream repo does instead is unchanged and is where its context is worth most: it **files findings about the hub as issues**, which is the opposite of self-certification and is how several hub defects have been found, and it **applies fixes to its own repo** per section 10. Any pull request it does open against the hub follows the same branching model as every other, targeting `develop` and never `main`. + +**Findings are a point-in-time snapshot. Stamp them and re-verify before acting.** [`spec/audit.py`][audit-runner] prints a run stamp (`audit run <UTC> | hub <sha>`) and, per repo, the exact commit it read (`@ <branch>@<sha>`). Anything derived from a run (a report, and especially an **onboarding or conformance issue**) quotes that stamp, so a reader can tell whether it still applies. A convergence issue is generated from the audit, never composed by hand: `spec/audit.py --issue <repo>` emits a ready-to-file title and body from that repo's live findings (grouped into must-fix, converge, and could-not-verify), so the issue content cannot drift from what the audit actually found and regenerates as the repo changes. + +**Verify a convergence before it is promoted with `--branch`.** `spec/audit.py --branch <ref> <repo>` reads that ref instead of the repo's registry `groundTruthBranch`, so a repo can audit its own `develop` while the work is still in flight rather than discovering the gaps after `main` has moved. The registry is not edited, the run is still read-only, and the run stamp names the override so a finding cannot be mistaken for one against ground truth. A ref that does not resolve is a single error naming it, never a baseline's worth of file-absent letters. + +**Re-running the audit needs a full hub clone with git history.** The verbatim stale-vs-modified classification walks the canonical's history (`git log` / `git show` from the hub root), so a shallow clone or a files-only checkout cannot answer "matches a past hub revision" and those findings are unreliable there. A downstream agent verifying one finding without the full history can instead compare against the current hub canonical on `main` (the whole file for a file-level unit, or the named `## heading` block for a verbatim section), which decides current-match but not stale-vs-modified. An agent picking up such an issue **re-runs the audit first and acts on the live result, not the pasted findings**: a repo moves between filing and pickup, so a stale block leads an agent to "fix" what is already fixed (re-requesting secrets that exist, attempting a no-op forward-sync). State the findings as evidence for *why* the issue was filed, never as the current state. + +**Reconcile `driftNotes` in the same pass.** A registry `driftNote` records a *current* deviation from the baseline. Once the deviation is resolved the note is deleted, not left describing finished work, since hand-maintained prose drifts silently otherwise. `spec/audit.py` flags two shapes of note, neither of them gated on the rest of the audit being clean. A note asserting outstanding work in prose ("pending", "not yet", "missing", "behind", ...) is contradicted outright by a clean audit, and where findings are open it is raised as a question of which one it means, because gating the check on a clean audit meant one standing finding a repo could not clear exempted its whole note list, and the repo carrying open findings is where a stale note is most likely. A note naming the check that would retire it, as an id in parentheses and matched with them (`(hugo.generator.pinned)`, the bare id is not detected), is the mechanically checkable shape and is surfaced on **every** run: the audit resolves the id against the catalog and confirms the repo declares its type, then hands the check itself to the auditor, since section 4 above is judged by hand. **So a note naming a check id is retired by a person, not by a run.** Write it that way anyway. The id says exactly what would close the note, and the surfaced finding puts that decision in front of whoever runs the audit rather than leaving the note to sit until someone rereads it. + +## 9. Escalate + +Surface spec questions rather than resolving them silently, for example the Python config-placement canonicalization, or a new construct no type covers. A repeated letter miss that many repos share is a signal the spec (not each repo) needs adjusting, so raise it. + +```mermaid +flowchart LR + finding["a finding from 7: Verdict Model"] -->|"the same miss, many repos"| s9["9: Escalate, fix the spec instead"] + finding -->|"one repo"| s10["10: Converge, branch + fix + PR"] + s10 --> review["review loop to green"] + review --> merge["maintainer merges"] + merge --> reaudit["re-audit, commit the report"] + s9 --> reaudit +``` + +## 10. Converge: Apply the Fixes + +Sections 1-9 (the audit and its report) are **read-only** and never touch the target. **Converging** is the separate follow-on phase: the drift the report found is **resolved by applying fixes to the target repo**, not left as a report. The convergence loop: + +- **Apply via a pull request on the target repo.** Branch from the target's `develop` (or `main` for a `main`-only repo), make the fix, and open a PR. Never push a fix directly to a protected branch, and never hand-edit a target outside a PR. +- **Drive the PR's Copilot review to green** - the same loop this repo runs (see [GOVERNANCE.md "PR Review Etiquette"][governance-pr-review-etiquette] and the [Copilot review runbook][copilot-runbook] in `.github/copilot-instructions.md`): request review on every push, address and resolve every thread, and confirm the review covers the head SHA. +- **Merge only with explicit maintainer approval.** The agent drives to green and stops. The maintainer merges. +- **One focused PR per drift class**, cross-referencing the audit finding. A sprawling all-drifts PR draws many review rounds and never feels done. +- **A `hub-only:` finding converges by deleting the file, not by updating it.** It is the one class where the fix removes content, so it is easy to convert into a re-vendor by reflex and end up refreshing a copy that should not exist. Delete the repo's copy and reach the hub's per [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling]. Confirm the disposition is `retire` before deleting anything: an untriaged hit may be the repo's own content at a shared path, and deleting that destroys work the hub never owned. +- **Any deletion sweeps the inbound references to the path, and the sweep is part of the deletion rather than follow-up.** This governs every removal and not only a `hub-only:` one, because nothing about it depends on who owned the file: the removal is one edit and finishing the job usually takes several more, so grep the path tree-wide first and read every hit. Then read the files whose job is to say what the repo holds, since a grep for the path finds uses of the file and misses descriptions of it: `GOVERNANCE.md` "Repository Layout" is the one that has gone stale this way, calling a deleted script "the apply script", which names no path and survives every search for one. A link whose target has an equivalent elsewhere is re-pointed at it, the hub's copy being that equivalent for a hub-hosted file, a **runnable command** citing the path is rewritten to the invocation that still works, and a mention with no equivalent anywhere is removed along with its reference definition, which [GOVERNANCE.md "Documentation Style Conventions"][governance-documentation-style] requires because an orphaned definition fails the no-unused-defs rule. Measured rather than hypothetical, in both directions: retiring `configure.sh` makes five lines of one repo's carried `repo-config/README.md` wrong, two of them commands a reader would run, and deleting a repo-owned nested `AGENTS.md` took three edits across two files, where removing the inline link alone tripped `MD053` on the definition it orphaned and would have failed CI rather than merely breaking prose. +- **Fix systemic drift in the hub, not per repo.** When many repos share a drift, fix the spec/rule (or add a machine check) here and let a re-audit re-flag it, rather than hand-patching each repo for the shared cause. + +The convergence model: the hub audits and the agent **applies** the fixes via target PRs, and the maintainer gates every merge. It supersedes any "the hub only reports; downstream operators apply by hand" framing. + +<!-- Workflow --> + +<!-- Repo --> +[audit-runner]: ./spec/audit.py +[codestyle]: ./CODESTYLE.md +[copilot-runbook]: ./.github/copilot-instructions.md +[divergences]: ./spec/divergences.json +[files]: ./spec/files.json +[governance]: ./GOVERNANCE.md +[governance-branching-model]: ./GOVERNANCE.md#branching-model +[governance-documentation-style]: ./GOVERNANCE.md#documentation-style-conventions +[governance-hub-hosted-tooling]: ./GOVERNANCE.md#hub-hosted-tooling +[governance-pr-review-etiquette]: ./GOVERNANCE.md#pr-review-etiquette +[host-setup]: ./docs/host-setup.md +[project-types]: ./spec/project-types.json +[readme-sections]: ./spec/readme-sections.json +[readme-structure]: ./spec/readme-structure.md +[repo-config]: ./repo-config/ +[repo-config-settings]: ./repo-config/settings.json +[reports]: ./reports/ +[repos]: ./registry/repos.json +[resync]: ./RESYNC.md +[scope-model]: ./spec/scope-model.md +[secrets]: ./spec/secrets.json +[spec]: ./spec/ +[standup]: ./STANDUP.md +[template]: ./reports/_template.md +[validate]: ./spec/validate.py +[workflow]: ./WORKFLOW.md +[workflows]: ./catalog/snippets/workflows/ diff --git a/CODESTYLE.md b/CODESTYLE.md index b8c3d004..0e714c72 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -1,62 +1,62 @@ -# Code Style and Formatting Rules - -This is the single code-style guide for the fleet. The **General** section applies to every language. Each **language section** (.NET, Python, Shell) is self-contained: a repo follows only the section(s) for the languages it ships and ignores the rest. A repo keeps the whole file rather than trimming it. An unused-language section costs nothing, the same whole-file model as [`.editorconfig`][root], whose inert `[*.cs]` block a non-.NET repo keeps. - -Cross-cutting *process* rules (PR titles, branching, US English, Markdown style, comments philosophy, workflow YAML, PR review etiquette, and the verification discipline that defines the pre-push lint gate) live in [GOVERNANCE.md][governance] and are not repeated here. - -## General - -These rules apply to every language in the repo. - -### Tooling Names and Casing - -Use each tool's official casing in task labels, docs, and prose, per the `comment-and-doc-style` Skill at `.agents/skills/comment-and-doc-style/SKILL.md` in the hub (not a repo-relative link, that path is hub-local and not carried into every fleet repo). - -### Clean-Compile Verification - -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]. -- **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 local commit/pre-commit gate is the repo's choice.** No single hook runner fits every language (a `dotnet`-tool runner like Husky.Net suits .NET but not Python), so none is mandated, but that is **not** a recommendation against commit gates. CI is the authoritative backstop regardless, and a local gate is an additive convenience a repo may wire and keep: Husky.Net (and `dotnet husky run` as a style step) for .NET, `pre-commit` for Python. Keeping a working gate is not drift. - -### Analyzer Diagnostics and Suppressions - -- **A new port is not a license to silence diagnostics.** Brownfield / just-ported status never justifies relaxing analyzer or linter severities or muting newly surfaced warnings. Fix them. (The only brownfield allowance is the one-time git-signing / line-ending migration described in [GOVERNANCE.md][governance] and [README.md][readme], which has nothing to do with code analysis.) -- **Suppress only genuine false-positives or deliberate, documented exceptions**, always at the **narrowest scope that fits**, in this order of preference: - 1. An **in-code annotation on the specific symbol**, with a justification, in the language's attribute/comment form, never a blanket pragma spanning a region. - 2. The **owning project's local config** when the exception is project-wide for one project (e.g. a test project's own `.editorconfig` / `pyproject.toml`). - 3. The **root / shared config** only when the suppression is genuinely applicable to **every** project in the repo. -- **Never blanket-relax a batch of rules project-wide** to get a port to build. The per-language mechanics (which attribute, which config key) are in each language section. - -### 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. - -## .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. - -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. - -## Python - -*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. - -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. - -## 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, `shellcheck` cleanliness, 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. - -<!-- Repo --> - -[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 +# Code Style and Formatting Rules + +This is the single code-style guide for the fleet. The **General** section applies to every language. Each **language section** (.NET, Python, Shell) is self-contained: a repo follows only the section(s) for the languages it ships and ignores the rest. A repo keeps the whole file rather than trimming it. An unused-language section costs nothing, the same whole-file model as [`.editorconfig`][root], whose inert `[*.cs]` block a non-.NET repo keeps. + +Cross-cutting *process* rules (PR titles, branching, US English, Markdown style, comments philosophy, workflow YAML, PR review etiquette, and the verification discipline that defines the pre-push lint gate) live in [GOVERNANCE.md][governance] and are not repeated here. + +## General + +These rules apply to every language in the repo. + +### Tooling Names and Casing + +Use each tool's official casing in task labels, docs, and prose, per the `comment-and-doc-style` Skill at `.agents/skills/comment-and-doc-style/SKILL.md` in the hub (not a repo-relative link, that path is hub-local and not carried into every fleet repo). + +### Clean-Compile Verification + +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]. +- **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 local commit/pre-commit gate is the repo's choice.** No single hook runner fits every language (a `dotnet`-tool runner like Husky.Net suits .NET but not Python), so none is mandated, but that is **not** a recommendation against commit gates. CI is the authoritative backstop regardless, and a local gate is an additive convenience a repo may wire and keep: Husky.Net (and `dotnet husky run` as a style step) for .NET, `pre-commit` for Python. Keeping a working gate is not drift. + +### Analyzer Diagnostics and Suppressions + +- **A new port is not a license to silence diagnostics.** Brownfield / just-ported status never justifies relaxing analyzer or linter severities or muting newly surfaced warnings. Fix them. (The only brownfield allowance is the one-time git-signing / line-ending migration described in [GOVERNANCE.md][governance] and [README.md][readme], which has nothing to do with code analysis.) +- **Suppress only genuine false-positives or deliberate, documented exceptions**, always at the **narrowest scope that fits**, in this order of preference: + 1. An **in-code annotation on the specific symbol**, with a justification, in the language's attribute/comment form, never a blanket pragma spanning a region. + 2. The **owning project's local config** when the exception is project-wide for one project (e.g. a test project's own `.editorconfig` / `pyproject.toml`). + 3. The **root / shared config** only when the suppression is genuinely applicable to **every** project in the repo. +- **Never blanket-relax a batch of rules project-wide** to get a port to build. The per-language mechanics (which attribute, which config key) are in each language section. + +### 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. + +## .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. + +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. + +## Python + +*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. + +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. + +## 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, `shellcheck` cleanliness, 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. + +<!-- Repo --> + +[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 f95ff037..c04304df 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -1,315 +1,315 @@ -# Fleet Governance Rules - -The cross-cutting rules every repo in the fleet follows. [`AGENTS.md`](./AGENTS.md) is the entry point agents read first and maps each task to the section here that governs it, and this file holds the rule text itself. Code style lives in [`CODESTYLE.md`](./CODESTYLE.md) and the CI/CD workflow contract in [`WORKFLOW.md`](./WORKFLOW.md). - -Read the one section a task needs rather than the whole file. `grep -n '^## ' GOVERNANCE.md` lists them. - -## Foundational Principles - -The specific rules in this file implement a few governing principles. Read these first: they are the reason the branching, release, and versioning rules are shaped the way they are, and every rule below serves one of them. - -- **Distribution respects the user: pull by default, push only where the channel forces it.** Docker images, GitHub Releases, and NuGet/PyPI packages are **pull**: the user decides when to consume them. A few channels are **push**: HACS surfaces a new release to every installed user as a pending update they did not go looking for, and a consumer that vendors from `main` picks up its current state. Because a release can reach users who did not ask for it, releasing is a deliberate act that marks a real functional change, never mechanical churn. This is why a **human merge never auto-publishes**: a release is a deliberate `workflow_dispatch`, or a conditional auto-release when the App merges a code-affecting Dependabot/codegen PR to `main` (Docker also refreshes on a weekly schedule). That rule, the no-op republish guarantee, and maintainer-gated version bumps all hold the same line: a needless release spends the user's attention and, on a push channel, acts on their machine. -- **Both branches stay in sync, so a promotion never needs a back-merge.** Dependabot and codegen target `develop` and `main` in parallel, so neither branch drifts and a `develop -> main` promotion stays a clean forward merge by default. That is exactly what lets the model be **signed, linear, and free of back-merges**: forward sync removes any need to merge `main` back into `develop`, which the rules forbid. If sync is ever broken (a change lands on one branch only, or normalizes a file on one side), restore it forward-only, never back-merge. See "Branching Model". (These auto-publish rules describe `release` repos. **Operational** repos differ, with direct-to-`develop` commits and a dispatch-only release. See "Operational Repositories".) -- **Two version numbers, two jobs.** The 2-digit `major.minor` in `version.json` carries human meaning: the maintainer raises it only for a functional change (feature, behavior or API change, breaking change), at their discretion, while NBGV owns the patch position and always increments with git height, so every build is uniquely versioned with no edit. Human-facing docs name the 2-digit line, and the toolchain guarantees monotonic builds. See "Release Model". -- **Contracts state what, not how, and favor reuse.** [`WORKFLOW.md`](./WORKFLOW.md) fixes required outcomes, not a required implementation, so two repos may satisfy a guarantee with different YAML. Within that freedom, apply good engineering practice: minimize duplication and maximize reuse, which is why the pipeline splits a carried, generic orchestration layer from a repo-owned build layer. - -## Durable Knowledge and Self-Improvement - -- **Durable knowledge lives in the committed docs, not in agent memory.** Anything a future agent must honor (a rule, a contract, a hard-won gotcha, a pattern worth repeating or one to avoid) belongs in a committed governance file (`AGENTS.md`, `CODESTYLE.md`, `WORKFLOW.md`, or a committed backlog such as a `README.md` TODO section). Agent memory does not survive a new session, a new machine, or a new environment, so it holds only environment-specific nuance and in-flight session state, never anything whose loss on reset would matter. A durable lesson left only in memory is lost to the next agent. -- **Keep the governance current as you work.** When work surfaces something durable (a rule worth enforcing, a recurring gotcha, a positive pattern to repeat, a negative one to design out), record it in the governance docs as part of that change, rather than leaving it in a local note or routing around it with a one-off workaround. Where the governing doc is carried from a template this repo cannot edit directly, propose the change upstream instead of only fixing it locally. Governance is not static: it improves by agents folding good patterns in and designing bad ones out. - -This section keeps the full rules and is surfaced at its decision moment by the `agent-conduct` Skill at `.agents/skills/agent-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. - -## Repository Boundaries and Write Safety - -A state-changing GitHub call is the highest-blast-radius thing an agent does here: it runs under the maintainer's identity, so one wrong target writes to another owner's repository as the maintainer, an outward-facing and hard-to-reverse act. These rules bound every write (a git push, an API mutation, a comment, a label, a merge) on any platform, and they bound a write to a checkout on disk as well, since a blanket add or a hard reset in a working tree another task is using destroys work without ever reaching GitHub. Reads are unrestricted, and how far a local read can be trusted is governed under "Verification Discipline" rather than here. The bounds below are on writes. - -- **Write only within the owner of the current project's repository.** Every state-changing call targets this project's `origin` or another repository under the same owner, which is the fleet the maintainer already administers. A broad or logged-in identity is capability, not permission: a token that *can* reach another owner's repository does not authorize writing to it. Writing under a **different owner** needs explicit human permission naming that repository, granted deliberately rather than assumed from a token's reach, and a "harmless test" write is still a write, so there is no probe exception. That boundary is where the harm sits, since the incident this rule exists for was a stray comment on a stranger's repository, not work across the maintainer's own projects. Reads from anywhere are fine. -- **Never fabricate, guess, or reuse an identifier passed to a write.** Every id a state-changing call consumes (a node id, a numeric id, a thread or comment id) is captured from a live query in the **same** session into a variable and passed from there. Do not hand-type an id, guess it, recall it from memory or an earlier session, or copy it from documentation or an example. Ids commonly resolve **globally**, so a wrong-but-valid id does not fail. It writes to the wrong target, in someone else's repository. If a query returns no id, stop rather than invent one to proceed. -- **A write is never a probe, and a write's output is never suppressed.** Never fire a state-changing call to see whether it works: decide it should happen, make it happen, and read the result. Never append output-discarding redirection or a force-success tail to a mutation (for example `>/dev/null`, `2>/dev/null`, `&>/dev/null`, `|| true`, `|| :`, `|| echo`), because the write's output is exactly what must be read. A write that appears to fail is **verified, not assumed harmless**, because the operation may have succeeded on the server while the client reported an error, so confirm the actual state before retrying or moving on. The ban targets hiding a *failure*. An ad-hoc call's response is the only signal you get, so `>/dev/null 2>&1`, `|| true`, and `|| echo`, which swallow the error stream or force success, are never acceptable on one. A committed script under `set -e` is a narrow exception: it may send a write's *stdout* to `/dev/null` to drop the success-response noise, because stderr stays visible and a failed write still aborts loudly (the hub's own `repo-config/configure.sh` does exactly this, and a repository reaches it there rather than carrying a copy). The exception is stdout-only suppression inside a reviewed, fail-loud script, never `2>&1` or a force-success tail, and never an ad-hoc command. -- **A refused write is reported, never re-shaped, and the maintainer's say-so does not lift a refusal by the harness.** These are two different permissions and only one of them is the maintainer's to give. When the agent harness refuses a write, the maintainer authorizing it in conversation does not change the outcome, and the identical call is refused again, so a second attempt is not worth making and reading the second refusal as a flake is how an agent starts hunting for another shape of the same request. **That hunt is the failure this rule exists to stop.** Re-expressing a refused `gh` command as a raw `gh api -X POST` reaches the same endpoint with the same identity and the same blast radius, having defeated the one control that stopped it, and it is the more dangerous version because the agent believes it has permission. So a refused write is never re-attempted through a different API surface, a different tool, or a rephrasing, and it is never routed around by the agent writing itself a permission rule, which is self-authorization whatever the maintainer said. Two routes remain, both of them the maintainer's: they add the permission rule themselves, or they run the command themselves. Raise it as a blocked decision naming those two (see "Communicating with the User"), and where the work needs the result rather than the call, say what the agent will verify once the maintainer has run it. **A refusal is also a fact about the contract, not just about the session**: where a required verification can only be performed by a write the agent is refused, the document requiring it says so and names who runs it, since a check that is mandatory and unperformable is quietly dropped and then reported as done. -- **Each task runs in its own checkout, in its own directory, on its own feature branch.** The unit is the task rather than the agent, since one agent moving between two repositories meets the same hazard as two agents sharing one tree, and a rule written per agent permits exactly the case that goes wrong. The commands that cross the boundary are the ordinary ones rather than the reckless ones, and each is correct in isolation: a blanket `git add -A` sweeps another task's uncommitted work into the commit, a `git reset --hard` deletes it, and a branch switch carries it into an unrelated change. The mechanical habit that holds the rule up is that a mutating command takes an absolute path, or a `cd` to one in the same invocation, rather than the working directory it inherited, because a read in the wrong directory is a wasted call and a write there is damage. -- **A task isolates into its own worktree before its first file edit, and a continuation re-isolates.** All new work begins by creating a unique git worktree (or clone) on its own feature branch, based on the branch work starts on for the repository's model per "Branching Model", which is `develop` unless the task is explicitly about `main`-only content. The primary checkout is the maintainer's own surface, so a session launched there isolates before writing rather than after noticing contention, and a session resuming a prior task creates a fresh worktree rather than resuming wherever its branch happens to be checked out, since a branch sitting checked out in a shared tree is exactly how two sessions end up in one checkout. The moment this rule binds is the first file edit, because the commit-time and review-time checks all run after another task's uncommitted work can already be swept. The worktree mechanics, the layout convention, and the cleanup are packaged as the `repo-worktree` Skill at `.agents/skills/repo-worktree/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, and this section keeps the rule. -- **A checkout another task is live in is left rather than shared, and a footprint already left there is undone deliberately.** Two signals say someone else is in the tree, a branch that changes when nothing you did changed it, and an edit of yours reverted with no conflict, and the response to either is to stop rather than to re-apply the edit, which is the instinct and the wrong one. Leaving and cloning your own costs about a minute against an incident that costs the better part of an hour, so it is the cheap move rather than the cautious one. Once you have written there, leaving it alone arrives too late, so save your work aside, restore only the files you touched, verify the tree is clean, delete your branch from that clone, and then say plainly what was touched, since a regenerated report left behind reads as the other task's own and is committed by whoever runs the next blanket add. - -## Representative Data in Agent-Authored Text - -Agent-authored text illustrates with data the agent constructed, never with data it observed in the maintainer's environment. This binds every surface an agent writes: pull request and issue comments, review replies, commit messages, code, tests, fixtures, and docs. Reading real data is unrestricted, and what is bounded is what an agent copies out of the environment into text that is committed or posted. The rule holds for a private repository as much as a public one, since a repository's audience changes with one settings toggle while the text stays exactly where it was written, and it holds where the data is the maintainer's own, since the exposure happens on their behalf before they can weigh it. - -- **Synthetic evidence is the better evidence, not a weaker substitute.** A case constructed to carry the defect demonstrates it exactly and any reader can re-run it, where observed data proves the same thing and can never be reproduced by anyone else. A filename built to contain a newline is a complete proof of a newline-handling defect, and the real directory it was found in adds nothing the proof needed. Reaching for observed data to make a finding more convincing inverts which of the two is the stronger evidence. Where observed data is what revealed the defect, name its shape, meaning the property that triggers the fault, and construct a case that carries that property. -- **The exposure is one-way.** A public comment is fetched, cached, and indexed the moment it posts, so editing it afterwards is mitigation rather than a fix, and the edit leaves the original readable in the comment's edit history to anyone who can read the repository. Text that has already landed is reported to the maintainer rather than quietly rewritten, since the decision on what to do about it, deletion included, is theirs. Do not quote the exposed data again while reporting or investigating it, because a transcript, an issue, or a commit message written about the exposure reproduces it somewhere new. -- **No checker closes this.** A pattern finds an absolute home path or a drive letter, and gating that subset is worth doing as a floor. The exposure this rule exists for was name-shaped, and a name is not pattern-detectable, so a search of the offending text for path-shaped strings returns nothing while the names sit in plain sight. A gate here catches the easy half, and mistaking it for the answer is what stops anyone looking at the other half, which is why this is a judgment an agent applies rather than a check it waits for. - -## Git and Commit Rules - -The fleet's mechanical git rules: default to staging rather than committing, stage by explicit path only and never with a blanket add, commit means commit and push, every commit is signed and carries the committer's own verified GitHub `noreply` identity, never force push, a history rewrite re-identifies only the commits it touches that aren't yours, and destructive git commands run only on explicit instruction. - -This is packaged as the `git-commit-conventions` Skill at -`.agents/skills/git-commit-conventions/SKILL.md` in the hub, not a repo-relative link since that -path is hub-local and not carried into every fleet repo. The summary above sketches the contract. -Read the skill for the full rules. - -## Branching Model - -Two workflow models, set per repo by the registry `workflowModel` field. Most repos are -`release`: squash-only feature branches into `develop`, merge-commit-only `develop -> main` -promotions, forward-only with no back-merges, and two promotion traps worth knowing before the -first one (never delete `develop`, resolve an EOL-only conflict by taking `develop`'s side). -**GitHub's own "default branch" repository setting reads `main`, but `develop` is where work starts and where in-flight content lives**, so a worktree or clone that defaults to "the default branch" lands on `main` and can silently miss content already merged to `develop` but not yet promoted. Branch from `develop`, on either workflow model, unless the task is explicitly about `main`-only content. -**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 -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. - -## Release Model - -The **two-phase model is the default**: PRs build fast, publishing is batched, a human merge -never auto-publishes on its own. See [`WORKFLOW.md`](./WORKFLOW.md) for the full CI/CD contract. -Publishing fires on a manual dispatch, a code-affecting bot push to `main`, or (Docker only) a -weekly schedule, and versioning is semantic and maintainer-controlled (NBGV owns the build number, -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 -since that path is hub-local and not carried into every fleet repo. The summary above sketches -the contract. Read the skill for the full rules, including the release-target build layer, the -no-op republish guarantee, and wrapper-repo upstream-version tracking. - -## Operational Repositories - -The registry `workflowModel` field is `release` (the default) or `operational`. **Operational** -repos track a live service's running state rather than shipping versioned units of delivery -(live-service config such as Home Assistant, ESPHome, Vantage, and home automation): commits go -directly to `develop`, CI runs on the push as advisory feedback only, a PR still exists for a -change worth reviewing, the `main` promotion gate is unchanged, and release happens only by manual -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 -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. - -Line-ending governance for an operational repo is in [Line Endings](#line-endings), where its `[*]` default follows the consuming app's native platform per the registry `lineEndings` field, not the fleet CRLF default. - -## Repository Onboarding and Conformance - -Every fleet repo is a standard-style repo the hub audits **downward** against its declared type, the model the fleet uses because managing downstream divergence is too costly. Three obligations follow, and they are not optional: - -- **Definition of done is the audit.** Onboarding or materially changing a repo is complete only when it passes [`AUDIT.md`](./AUDIT.md) for its type, **or** carries a committed `reports/<repo>/audit.md` plus a tracking issue for the residual deltas. Do not leave a repo partially stood up and unrecorded, which is itself a defect. Stand a repo (or a new type) up with `STANDUP.md`, and verify it with [`AUDIT.md`](./AUDIT.md). The two share the same manifests, so a repo stood up correctly passes the audit by construction. A repo that is stood up already and has fallen behind the hub is brought back into line with `RESYNC.md`, which is the third procedure and the one a request to sync a repo with the hub means. It exists because a findings list does not state the order its remedies apply in, and one of them deletes a file rather than updating it. -- **The registry is ground truth about reality, not intent.** After any conformance change, reconcile the repo's `registry/repos.json` entry (`status`, `types`, `releaseTrigger`, `workflowModel`, `driftNotes`) to what the repo actually is. `spec/validate.py` proves the catalog is self-consistent. It does **not** prove the catalog matches the live repo. A `validate.py`-clean entry can still be false, and a stale entry is a finding. -- **Prove the docs suffice.** The onboarding docs are sufficient only when a context-free agent stands each supported repo shape (project type(s) plus workflow model) up from them alone. The cold-start self-test in `STANDUP.md` and the conformance matrix track that. A shape that cannot be stood up cold is a documentation defect, fixed in the hub, not worked around per repo. - -## Hub-Hosted Tooling - -The fleet's tooling lives in the hub once and a repository runs it from there rather than holding a copy. A carried script is current only until the next fix to it, and a repository that misses the sweep does not fail loudly, it audits itself with an older gate while reporting the same command in its output. Removing the copy removes the sweep, the stale-copy detection, and the disposition each stale copy earns, all at once. The hub is the repository [`AGENTS.md`](./AGENTS.md) "Fleet Bootstrap" names, and that section is the entry point whenever nothing else present says where it is. - -**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. - -**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. - -**A loader is outside this section rather than exempt from it.** The rule above governs a tool that reads hub content, because a tool reads the payloads, tables, and sibling modules beside it. A loader reads none of them: it obtains a tree and hands control to a tool inside that tree, on a host that cannot yet obtain one. The bound is what it may contain rather than who runs it, and it is one line: a loader references no path inside the tree it fetches except the single entry point it hands control to, and everything else it touches is the machine or the network. A loader that grows a second path into that tree has become a tool and is governed above. - -**A report or finding a hub tool produces names the hub commit it ran from.** The tool moves independently of the repository it measures, so a verdict carrying no hub commit cannot be re-run, and two runs that disagree cannot be attributed to the tree or to the tool. The obligation is the runner's rather than the tool's, since a tool reports on the repository it measures rather than on itself, so the commit is read from the hub checkout and written into the report beside the verdict. This is the same requirement "Verification Discipline" places on any claim that gets acted on. - -**CI reaches the same tooling as a pinned action or reusable workflow.** A runner holds no hub checkout, so a workflow consumes the hub's composite action or reusable workflow and pins it to a commit SHA, per the action-pinning rule under "Workflow YAML Conventions". A standard workflow whose job graph is identical across repos of a type is reached the same way, as a `workflow_call` task the hub hosts once, and the repository carries only the caller stub and a composite-action hook for what is genuinely its own. The pin is what makes a released repository's gate reproducible, since an unpinned consume lets a later hub commit fail a re-run of a change that already passed. Branch-dependent behavior belongs inside the consumed action, because `uses:` takes no expressions and a per-branch ref therefore cannot be selected in the workflow file. - -**An unreachable hub means the tool did not run, and that is the result reported.** A carried copy still works offline and a reached one does not, which is the cost this model trades away and the reason to state the failure rather than route around it. A check that cannot run reports itself as not run, never as clean, which is the silent-narrowing failure "Verification Discipline" names. A hand-rolled substitute is not the tool either: a reconstructed gate encodes its author's reading of the rule rather than the rule, agrees with no other repository, and is the duplicated effort this model exists to end, so an agent that cannot reach the hub says so and stops. - -## Pull Request Title and Commit Message Conventions - -A PR title and a commit message share one contract: an imperative subject, 72 characters or fewer, no trailing period, no vague titles like `update stuff` or `wip` (Dependabot's `Bump X from Y to Z` is fine as-is), no unsolicited `Co-Authored-By:` lines, and no release-bump magnitude in the title, since Nerdbank.GitVersioning computes the next version from `version.json` and git history. - -This is packaged as the `comment-and-doc-style` Skill at `.agents/skills/comment-and-doc-style/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. The summary above sketches the contract. Read the skill for the full format, rules, and examples. - -## 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. - -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. - -### Comments - -The full comment philosophy, what earns one, structure, capitalization, growth discipline, is in the `comment-and-doc-style` Skill referenced above. - -### 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. - -### Line Endings - -The full CRLF/LF policy (`.editorconfig` and `.gitattributes` defaults and pins, choosing an ending for a new file type, operational-repo overrides, editing discipline, and auditing) is in the `comment-and-doc-style` Skill referenced above. - -### Sentence Structure - -ASD-STE100's structural half is the adopted house style: short sentences, one instruction per sentence, active voice, and imperative mood for procedure steps. Its controlled dictionary is deliberately not adopted. The full rules, the sentence word cap, and the opt-in `sentence-length` check that enforces the cap are in the `comment-and-doc-style` Skill referenced above. - -## Verification Discipline - -The checks that separate work actually done from work that merely reports success. Their unifying property: **every failure below is green.** A skipped job and a passing job are indistinguishable in the aggregated required check, a pattern that matches less still exits zero, and a gate that stops gating still reports success. No linter, status check, or review layer catches any of them. - -- **Locate every check a change owes before running any of them, and CI's coverage is not that list.** The checks are read from what the repository declares, meaning its [`OPERATIONS.md`](./OPERATIONS.md) "Local Verification" section alongside the workflows, rather than inferred from whatever the pipeline happens to run. Part of a repository's contract is routinely unreachable from a runner, a redirect no build serves, a deploy no pull request performs, hardware no runner holds, so the check covering that part lives in a document rather than in a workflow and is run by hand before the pull request opens. Green is then the precise signal that it was skipped, because the pipeline reports success over the half it reaches while saying nothing about the half it cannot. Reading a document's own description of itself is not how such a check is found, since a topical document is named for its most visible function, usually a post-merge one, and an accurate description of that function routes a pre-merge task away from the file holding the gate. The destination is declared fleet-wide for that reason, so finding the check never depends on how well one repository worded a pointer to it. -- **A test must assert the mechanism it names, and a gate has to be watched failing.** Label each case by the behavior it proves, then write the case that reintroduces the fault and confirm the gate objects to it. A case that passes for an incidental reason, the right answer reached by the wrong path, is worse than no case, because it is later cited as evidence. A proof that restates the gated data instead of reading it proves only that the function works, so drive the real table or the real config. And a gate that finds nothing is indistinguishable from a gate with nothing to find, so assert a floor on what a healthy run covers. -- **Gates, filters, and gate-like watchers fail loud, never narrow quietly.** A pattern that silently matches less, an allowlist that silently stops matching, or a gate that silently stops gating all report success while doing nothing. When a construct exists to notice something, make the not-noticing case produce an error or an annotation. An identity allowlist used as a gate, for one, must raise an error when its list stops matching, not silently pass everything through. -- **Config with a uniqueness rule is validated on read, and its consumers assert what it promised.** A repeated key in a lookup table is not a precedence question to settle quietly, it is two answers to one question, and keeping whichever came last picks one of them where the reader sees no choice being made. Fail on the duplicate at the point the config is read, so the code downstream can rely on the invariant instead of re-deriving it. -- **Validate and read on the same normalized key.** A guard that compares stripped names while the join looks up the raw one passes a padded key and then matches nothing, so the exact fault the guard exists to stop is sitting inside the guard. Normalize once at the boundary and use that one value for both the check and the lookup. -- **Run the repo's whole lint gate before every push, not the parts that look relevant.** CI runs all of them, so a partial local run only defers the failure, and the tool most likely to catch a given change is often the one it seems least about (an edit that manipulates line endings is exactly when `editorconfig-checker` matters). The repo documents each linter's known-working invocation, and this rule is that **all** of them run. -- **Editing CRLF files programmatically: `.` matches `\r` in a regex**, so a captured line keeps its carriage return and rejoining with `\r\n` yields `CRCRLF`. A text-mode rewrite has the mirror failure, silently flattening CRLF to LF. Prefer line-based edits (`splitlines(keepends=True)`) or literal replacement over regex reassembly. In Python the text-mode failure is the *default*: `Path.read_text()` decodes through universal newlines and `write_text()` writes `\n` back, so a read-edit-write round trip flattens the whole file while the edit itself looks correct. Pass `newline=''` to both, or work in bytes. This is the mechanism behind the Line Endings warning above, and it is worth naming because the corruption is invisible in a rendered diff. -- **Scope a check by what the project declares, not by the file that prompted it.** A check written while editing one file tends to cover that file's language and stop, and then reports success on every other surface the rule governs. Read the declared types, or the config that enumerates them, and cover each one, then assert a floor per surface so a table that narrows fails loudly instead of passing quietly. A rule about comments means every comment syntax the project ships, and a format that carries comments in practice counts even where its specification says otherwise. -- **Never edit source through a shell heredoc when the text carries backslash escapes.** The shell consumes the escape and writes an invisible control character in its place, so a `\b` inside a regex becomes a backspace and the pattern silently matches nothing while every test still passes. Use a file-editing tool for such text. When a check inspects text for control characters, use `str.isprintable()` rather than a codepoint floor, since DEL and the Unicode format characters sit above 32 and are equally invisible in a diff. -- **Never edit an active `.code-workspace` file.** A workspace file rewritten on disk can make VS Code reload the window, and a reload destroys the running agent session's context, so the work in flight is lost with nothing to catch it, and the trigger is not fully characterized (an agent's edit has caused the reload where a human's identical edit did not). Surface the needed change for the maintainer to apply by hand. -- **A green check is not evidence the work happened.** A skipped job and a passing job are indistinguishable in the aggregated required check. When a job exists to exercise something, confirm from its log that it ran and produced the output it promises. -- **A local clone is not the branch it names, it is whatever that clone last fetched.** Reading a checkout on disk answers what that clone last saw, so a finding taken from one carries a date nobody stated, and two failures of exactly that shape are on record from one session: a repository reported as still drifted on a file whose fix had already merged, and a repository reported as missing a file it carries because the checkout sat on an older branch. Read the live ref through the API where the claim will be acted on, or fetch immediately before reading, and name the ref and the commit in any finding a local read produced. A clone stays the right tool for anything needing history or a build, which an API read cannot give. -- **A "does not exist" claim names the branch it was checked against.** A worktree or checkout answers for whichever ref it was built from, and that ref is not necessarily the one the content lives on: a `release`-model repo carries in-flight content on `develop`, per "Branching Model" above, well before it reaches `main`, so a worktree defaulted to the fleet's default branch can hold nothing while the repository holds everything. Before reporting a file, a directory, or a piece of content as absent anywhere in a repo, check it against the branch the repo's own model designates as current for that kind of content, not only whichever branch a worktree or checkout happened to default to, and name the branch the negative claim was checked against in the finding itself. -- **A launched process is not a result, and a cause nobody observed is not a diagnosis.** "The watcher is armed" names a process rather than a finding, so what gets reported is the output that process produced, and where it produced none, that absence is the report. The failure it prevents is an agent standing still on a condition that was met half an hour earlier, having announced the wait and never read it. Naming an external cause for such a stall afterwards, a throttle or a quota that appears nowhere in the record, turns a local defect into a story about someone else and closes the investigation on the wrong party, so read the record for the cause before naming one, and where the record does not carry it, report the cause as unknown. -- **A workflow change is only fully exercised by CI.** Extracting a `run:` block and executing it locally validates the script and nothing else, because `secrets: inherit`, `permissions:`, `needs:` wiring, and reusable-workflow inputs resolve only in a real run. -- **Platform-specific code is "verified" only on the platform it runs on.** PowerShell on Windows, a macOS-only `mktemp`/`ssh-agent` behavior, a WSL-specific path quirk: an agent reasoning about such code from a different host, however carefully, has not executed it, and reasoning by structural analogy to an already-tested equivalent on another platform ("the POSIX version works, so the PowerShell version should too") is a plausible first pass, not verification. State it as exactly that, an unverified structural match, and never in the same words used for a tested fact. When no agent in the loop has access to the target platform, say so, and either defer the platform-specific portion to a human or an agent that has that access, or ship it clearly labeled unverified. -- **A review flags an instance, so fix the class.** When a reviewer cites one stale claim, one silent-narrowing pattern, or one mis-worded contract, sweep for its siblings before replying. Reviewers sample rather than enumerate. - -This section keeps the full rules and is surfaced at its decision moment by the `agent-conduct` Skill at `.agents/skills/agent-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. - -## PR Review Etiquette - -The provider-agnostic review-loop contract every fleet repo follows: 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 the low-confidence ones a review body collapses rather than threads, and reply and resolve. Never merge on a green or CLEAN merge state alone, since that field 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 finding, which opens no thread at all, unanswered. - -This is packaged as the `pr-review-conduct` Skill at `.agents/skills/pr-review-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. The summary above sketches the contract. Read the skill for the merge gate, the expected loop, and how a finding is closed. - -The provider-specific mechanics this contract needs to actually drive GitHub Copilot, how to request a review, poll for it, match the suppressed-findings heading, verify coverage, and reply or resolve a thread without a hand-typed id, live in [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) "GitHub Copilot Review Runbook", unaffected by this pointer. That content is read directly by the Copilot reviewer bot, which cannot read a Skill, so it stays fully written out rather than being packaged the same way. - -## 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. - -This section keeps the full rules and is surfaced at its decision moment by the `agent-conduct` Skill at `.agents/skills/agent-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. - -## Workflow YAML Conventions - -These conventions describe the target state. New and modified workflows must respect them. The rest of the repo is expected to be brought up to the same standard. Sweep PRs that apply a rule everywhere are welcome when a rule changes. - -This section and [`WORKFLOW.md`](./WORKFLOW.md) keep the full rules, this section winning where the two overlap, and both are surfaced by the `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. - -- **Action pinning**: pin **every** action, first-party (`actions/*`) and third-party alike, to a commit SHA with a trailing `# vX.Y.Z` comment, so Renovate / Dependabot can still bump it but a tag swap can't change the executed code. Use `# vX` (major-only) only when the upstream's floating major tag doesn't correspond to a specific patch/minor release SHA, since pinning to the floating-tag SHA still gives the SHA guarantee, the version comment just records the major line. Documented exception (no SHA pin at all): `dotnet/nbgv` is consumed via `@master` because the upstream tag stream lags `master` substantially and Dependabot's tag-tracking would propose a downgrade. **This applies to repo-owned build-layer leaves too**, since a leaf owning its build specifics is not a reason to use floating tags, and Dependabot still bumps SHA pins (updating the SHA + version comment). -- **Filename**: reusable workflows (those with `on: workflow_call`) end in `-task.yml`. Entry-point workflows (`on: push` / `pull_request` / `schedule` / `workflow_dispatch`) do NOT use the `-task` suffix. They end with what they do: `-pull-request.yml`, `-release.yml`, etc. The suffix carries semantic meaning: a `-task.yml` file is meant to be `uses:`-d, never triggered directly. -- **Workflow `name:`** (the top-level `name:` field): reusable workflow names end in **"task"** (e.g. `Build PyPI library task`), and entry-point workflow names end in **"action"** (e.g. `Publish project release action`, `Test pull request action`). The displayed action name in the GitHub Actions UI tells you at a glance whether you're looking at an orchestrator or a callee. -- **Job and step `name:` suffixes**: every job's `name:` ends in **"job"** and every step's `name:` ends in **"step"**, including the PR-gate aggregator, whose `name:` is a required-status-check `context:` in a branch ruleset (`Check pull request workflow status job` in `test-pull-request.yml`). A ruleset-bound job's `name:` and its ruleset `context:` are the **same string**: rename them **together**, updating the live ruleset and `repo-config/{develop,main}.json` in lockstep with the job `name:`, never one without the other, or required-status-check enforcement silently breaks. There is no un-suffixed exception. -- **Concurrency**: top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }` so a fresh push supersedes an in-flight run on the same ref. **Documented exceptions** (both record the rationale inline in their header comment): (1) [`merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml) uses `cancel-in-progress: false` because the merge-bot's job model (enable-auto-merge on opened, disable-auto-merge on maintainer-pushed synchronize, with method dispatched by base) requires each event to run to completion in arrival order, because cancellation would leave auto-merge in an inconsistent state. (2) [`publish-release.yml`](./.github/workflows/publish-release.yml) uses both a **global, ref-independent group** (`group: ${{ github.workflow }}`, dropping the usual `-${{ github.ref }}`) and `cancel-in-progress: false`. It publishes shared ref-independent artifacts (both branches' Docker tags/caches and GitHub releases) on schedule/dispatch regardless of the triggering ref, so a ref-scoped group would let a scheduled run (ref `main`) and a manual dispatch (ref `develop`) run concurrently and double-push, and cancelling a publish mid-flight can leave a partially pushed tag set or a half-created release. The global group + queueing serializes every publish run to completion. -- **Shells**: every bash surface, a multi-line `run:` block and every committed `.sh` script alike, starts with `set -Eeuo pipefail`: fail fast, fail on undefined vars, fail on a failed pipe segment, and let an `ERR` trap inherit into functions, subshells, and command substitutions (`-E`). The `-E` is defense in depth: the fleet ships no `ERR` trap today, so a script that later adds one inherits the behavior instead of silently losing it. -- **Conditionals**: multi-line `if:` uses folded scalar `if: >-` so YAML preserves whitespace correctly. Literal block (`if: |`) is wrong because it embeds newlines inside the boolean expression. -- **Boolean inputs**: workflows triggered both via `workflow_call` and `workflow_dispatch` must declare each boolean input in *both* trigger blocks, since one definition does not propagate to the other. `workflow_call` delivers booleans as actual booleans, and `workflow_dispatch` delivers them as the *strings* `"true"`/`"false"`. Any `if:` consuming a boolean input must compare against both forms: `if: ${{ inputs.foo == true || inputs.foo == 'true' }}`. -- **Validate input/state consistency at entry, fail fast**: when a workflow's inputs must satisfy a cross-input or input-versus-derived-state invariant (e.g. the release branch must match the computed version's prerelease status, or two inputs are mutually exclusive), assert it **once** in a dedicated entry validation step/job that the downstream jobs `needs:`, before any expensive build or publish work, not as partial checks scattered deep in later jobs. One gate that fails fast with a clear `::error::` beats a late or one-directional check. Examples: `build-release-task.yml`'s `validate-release` job (branch-versus-prerelease, both directions) and `publish-docker-readme-task.yml`'s "Validate inputs step". -- **Reusable workflows**: job-level `permissions:` are validated *before* the `if:` evaluates, so even a skipped job needs valid permissions declared. A `release` job with `permissions: contents: write` and `if: ${{ inputs.publish }}` will still cause `startup_failure` on a caller that doesn't grant `contents: write`. Either declare permissions at the call site, or omit the inner block and inherit. -- **Allowlist `success` and `skipped` explicitly** when chaining jobs across optional dependencies, since `!= 'failure'` lets `cancelled` through (timeout, runner failure, manual cancel). Use `(needs.X.result == 'success' || needs.X.result == 'skipped')`. -- **Artifact retention**: workflow artifacts are an intra-run handoff only, with durable copies living on the GitHub release rather than in workflow artifacts, so they must not survive the run and accumulate against the small account-wide artifact-storage quota. **Clean up each transfer artifact surgically at its point of consumption**: the job that downloads it deletes it by exact name/pattern right after consuming it (the `github-release` job deletes `release-asset-<branch>-*` after attaching them to the release, and `publish-release.yml`'s `publish-pypi` deletes `pypilibrary-build-<branch>` after publishing). Deletion needs `actions: write` granted on that job, and for a reusable callee (e.g. `github-release` inside `build-release-task.yml`) the **caller** grants it (`publish-release.yml`'s `publish` job does). **Never blanket-delete the run's artifacts** (`gh api .../artifacts --jq '.artifacts[].id'`). That also destroys diagnostic/log artifacts and the build-records actions emit automatically (`docker/build-push-action`'s `.dockerbuild`), which are exactly what you need to debug a failed run. Set `retention-days: 1` on **every** explicit `upload-artifact`: it is the failure-path backstop, since a job that dies before its consumer runs leaves its artifact to be reaped within a day, so no separate terminal cleanup job is needed. A repo customizing these jobs must preserve the consume-then-delete shape. -- **Docker layer cache**: cache to/from a registry tag (`type=registry`, e.g. `buildcache-<branch>` on Docker Hub), not the GitHub Actions cache (`type=gha`), to keep large image layers off the 10 GB Actions cache. A **multi-image** repo uses a **per-image** buildcache tag (`<repo>:buildcache-<branch>` for each image, plus the base image's own tag and inline cache). It does not fall back to `type=gha` for the extra images. -- **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. - -**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), and, **for a repo that carries `.ps1` files**, **PSScriptAnalyzer** the same way (it has no action either). 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. -- **The `.husky/pre-commit` hook** runs **language formatting** and the **diff-scoped doc gates**, never Docker and never a network call, so it stays fast. The formatting half is whatever the repo's own language needs, CSharpier and `dotnet format` for .NET or ruff 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 what the commit changes 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. 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, since it resolves a same-owner pin against the GitHub API and a hook that needs a network fails offline. A repo enables the hook per clone with `git config core.hooksPath .husky`, and 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. - -The Docker invocations below are the same ones the VS Code tasks use, for ad-hoc or headless (agent) runs. - -- **editorconfig-checker** (line endings + charset across the tree): - - ```sh - docker run --rm --pull=always -v "$PWD":/check --workdir /check mstruebing/editorconfig-checker:latest - ``` - -- **actionlint** (GitHub Actions workflow YAML, run after any `.github/workflows/` edit, since workflow-only changes are not smoke-built): - - ```sh - docker run --rm --pull=always -v "$PWD":/repo --workdir /repo rhysd/actionlint:latest -color - ``` - - The `rhysd/actionlint` image bundles `shellcheck`, so it also validates `run:` shell blocks. The direct-binary/curl-installer path is often sandbox-blocked, so use Docker. - -- **markdownlint-cli2** (Markdown, mirroring the davidanson VS Code extension via the shared [`.markdownlint-cli2.jsonc`](./.markdownlint-cli2.jsonc), so the CLI and IDE agree): - - ```sh - docker run --rm --pull=always -v "$PWD":/workdir --workdir /workdir davidanson/markdownlint-cli2:latest "**/*.md" - ``` - -- **cspell** (spelling in user-facing docs, with the word list and exclusions in [`cspell.json`](./cspell.json)): - - ```sh - docker run --rm --pull=always -v "$PWD":/workdir --workdir /workdir ghcr.io/streetsidesoftware/cspell:latest --no-progress README.md HISTORY.md - ``` - -- **PSScriptAnalyzer** (PowerShell, the peer of the shellcheck step, **only applies to a repo that carries `.ps1` files**, which carries `PSScriptAnalyzerSettings.psd1` alongside them with the excluded rules and their reasons): - - ```sh - docker run --rm --pull=always -e PS_SCRIPTS="$(git ls-files '*.ps1')" -v "$PWD":/mnt --workdir /mnt mcr.microsoft.com/powershell:latest \ - pwsh -NoProfile -Command ' - Set-PSRepository PSGallery -InstallationPolicy Trusted - Install-Module PSScriptAnalyzer -RequiredVersion 1.23.0 -Force -Scope AllUsers - Import-Module PSScriptAnalyzer - $files = $env:PS_SCRIPTS -split "\s+" | Where-Object { $_ } - if (-not $files) { Write-Host "no PowerShell scripts are tracked"; exit 0 } - $found = @() - foreach ($file in $files) { $found += Invoke-ScriptAnalyzer -Path $file -Settings ./PSScriptAnalyzerSettings.psd1 } - Write-Host "Checked $($files.Count) file(s)" - if ($found) { $found | Format-Table RuleName,Severity,ScriptName,Line,Message -AutoSize | Out-String -Width 200 | Write-Host; exit 1 } - Write-Host "no findings" - ' - ``` - - The module version is pinned beside the image, because the image alone does not fix it and a floating install makes a local run a different check from CI. 1.23.0 rather than the newest, since 1.24.0 needs a newer `System.Management.Automation` than the image carries and fails to import after installing cleanly. The file list comes from `git ls-files` for the same reason the shellcheck step uses it, and the count is printed because a run that read no files reports the same clean as one that read them all. - - **The list splits on whitespace rather than on a newline, and the regex is double-quoted.** A shell joins the file list with newlines and PowerShell joins it with spaces, so a newline-only split hands the analyzer one path holding every file, which it reports as one file it cannot find followed by a clean run over nothing. The double quotes are what let the whole invocation stay inside the single-quoted `-Command` a shell passes, since PowerShell escapes with a backtick and leaves the backslash alone. Run verbatim it reports `Checked 5 file(s)` from either shell. - - In a configured editor the davidanson extension is enough. Use the Docker CLI when there's no IDE (agent/headless) or to confirm a clean run 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. -- **A repo's platform ceiling is set by its dependencies, not by tooling effort, so decide it per repo before writing dev tooling.** Narrow below the default only for a hard runtime ceiling, where the code can only execute or test on one platform (e.g. a Home Assistant integration is Linux-only: HA Core has POSIX-only dependencies and will not run natively on Windows, so even maximal tooling yields only lint-only there). The narrowing axis is where code *executes* for dev and testing (native, SSH-remote, container, or CI), never where editing happens. -- **Record a narrowed platform and its reason in the repo** (README/AGENTS) so the restriction reads as a deliberate dependency ceiling, not an omission. - -## Devcontainer - -Contributors commit to this repo with signed commits. The SSH-signing setup lives in [docs/ssh-signing.md](./docs/ssh-signing.md), host prerequisites in [docs/host-setup.md](./docs/host-setup.md), and devcontainer SSH-agent forwarding in [docs/devcontainer.md](./docs/devcontainer.md). This repo ships no application toolchain. The per-language devcontainer definitions it once used are kept as reference under [`catalog/snippets/devcontainer/`](./catalog/snippets/devcontainer/). - -## Editor and Tasks - -- **VS Code is the primary IDE, and the experience favors it.** Prefer VS Code tasks and launch configurations for building, running, and testing over ad-hoc shell scripts. A script is the fallback, not the default. -- The `.code-workspace` file carries the shared editor settings and the recommended-extension set. **All VS Code settings and extension recommendations live only here, never in a standalone `.vscode/settings.json` or `.vscode/extensions.json`** (`.vscode/` holds only `tasks.json` and `launch.json`). A **standard set** of extensions applies to every repo (markdownlint, cspell, editorconfig, markdown-all-in-one, better-todo-tree, github-actions, actionlint, shellcheck, claude-code); **language-specific** extensions are added per project (.NET: csdevkit, csharpier; Python: python, pylance, ruff, mypy; Docker: the Docker extension). -- The Table of Contents is maintained by the Markdown All in One extension, and `markdown.extension.toc.levels` in the workspace sets which heading levels it includes (see the Markdown rules for the authoring convention and the `<!-- omit from toc -->` exclusion marker). -- **Agents: editing the active `.code-workspace` can reload the VS Code window and drop the agent's session.** Commit all state first, prefer opening the folder rather than the workspace while editing it, or leave workspace edits to the maintainer (a maintainer edit does not reload). - -## Repository Details - -Every repo's GitHub repository details (the About panel) follow a fixed convention so the fleet stays consistent and self-describing. - -- **Description** matches the README's **tagline**, its first non-empty line after the `#` H1 heading, as plain text, stripping Markdown links (`[text](url)` and `[text][ref]` become `text`) since a description is not rendered. It is that one line and not the paragraph it opens: a README may carry further paragraphs below the tagline, and no mirror reads them. The README is the source of truth: set the description from it (`gh api -X PATCH repos/<owner>/<repo> -f description=...`), never the reverse. When the current description is *more specific* than the README (a chip revision or variant the README omits), surface the drift to the maintainer rather than silently discarding the detail, and the fix is to sharpen the README so the description follows it. Keep the line at most **100 characters**, Docker Hub's short-description cap and the tightest surface it feeds. For a repo that publishes a Docker image, the **Docker Hub short description** mirrors the same tagline, so one canonical sentence carries to the README, the About panel, and Docker Hub alike. Docker Hub receives it from the About panel, which the docker-readme task reads at publish time, so an About panel left diverged from the README is carried onward rather than corrected there. -- **Topics** are optional, and any that are present match the repo's actual content. Do not invent topics to fill the field. -- **Include in the home page**: Releases on, Deployments off, Packages off. These toggles are UI-only, since the REST and GraphQL APIs neither read nor write them, so they are set by hand and cannot be audited through `gh`. - -## Repository Layout - -- [`AGENTS.md`](./AGENTS.md): the agent entry point, carrying context and delegation rules plus the map to the sections below. -- [`GOVERNANCE.md`](./GOVERNANCE.md), [`CODESTYLE.md`](./CODESTYLE.md), [`WORKFLOW.md`](./WORKFLOW.md), [`AUDIT.md`](./AUDIT.md): the governance and audit docs. This file is the cross-cutting-rules authority. -- [`spec/`](./spec/): the machine-readable ground truth, covering project-type requirements, the file/section baseline, secrets, the host tool contract and its floors, and the preferred README structure. -- [`registry/repos.json`](./registry/repos.json): the fleet registry, holding every project, its type(s), and its status. -- [`repo-config/`](./repo-config/): branch rulesets, the fleet settings, and the apply script every repo is configured by, kept out of `.github/` (which is Actions-owned). The payloads carry to the fleet and the script is reached here. -- [`catalog/`](./catalog/): reusable reference snippets (workflows, configs, devcontainers) the audit compares implementations against. -- [`scripts/`](./scripts/): the fleet's lint and review tooling, with the deterministic checks also gating CI, for the documented rules no CI linter checks. Its unit tests sit apart under [`scripts/tests/`](./scripts/tests/). Hosted here and reached rather than carried, per "Hub-Hosted Tooling". -- [`docs/`](./docs/): hub-only procedure and measurement docs (host setup, config carry, signing, agent token cost), none of them carried downstream. -- [`reports/`](./reports/): per-repo audit output. -- [`.github/workflows/`](./.github/workflows/): this repo's own lint-only CI and source-only release, the workflow contract it dogfoods. - -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. +# Fleet Governance Rules + +The cross-cutting rules every repo in the fleet follows. [`AGENTS.md`](./AGENTS.md) is the entry point agents read first and maps each task to the section here that governs it, and this file holds the rule text itself. Code style lives in [`CODESTYLE.md`](./CODESTYLE.md) and the CI/CD workflow contract in [`WORKFLOW.md`](./WORKFLOW.md). + +Read the one section a task needs rather than the whole file. `grep -n '^## ' GOVERNANCE.md` lists them. + +## Foundational Principles + +The specific rules in this file implement a few governing principles. Read these first: they are the reason the branching, release, and versioning rules are shaped the way they are, and every rule below serves one of them. + +- **Distribution respects the user: pull by default, push only where the channel forces it.** Docker images, GitHub Releases, and NuGet/PyPI packages are **pull**: the user decides when to consume them. A few channels are **push**: HACS surfaces a new release to every installed user as a pending update they did not go looking for, and a consumer that vendors from `main` picks up its current state. Because a release can reach users who did not ask for it, releasing is a deliberate act that marks a real functional change, never mechanical churn. This is why a **human merge never auto-publishes**: a release is a deliberate `workflow_dispatch`, or a conditional auto-release when the App merges a code-affecting Dependabot/codegen PR to `main` (Docker also refreshes on a weekly schedule). That rule, the no-op republish guarantee, and maintainer-gated version bumps all hold the same line: a needless release spends the user's attention and, on a push channel, acts on their machine. +- **Both branches stay in sync, so a promotion never needs a back-merge.** Dependabot and codegen target `develop` and `main` in parallel, so neither branch drifts and a `develop -> main` promotion stays a clean forward merge by default. That is exactly what lets the model be **signed, linear, and free of back-merges**: forward sync removes any need to merge `main` back into `develop`, which the rules forbid. If sync is ever broken (a change lands on one branch only, or normalizes a file on one side), restore it forward-only, never back-merge. See "Branching Model". (These auto-publish rules describe `release` repos. **Operational** repos differ, with direct-to-`develop` commits and a dispatch-only release. See "Operational Repositories".) +- **Two version numbers, two jobs.** The 2-digit `major.minor` in `version.json` carries human meaning: the maintainer raises it only for a functional change (feature, behavior or API change, breaking change), at their discretion, while NBGV owns the patch position and always increments with git height, so every build is uniquely versioned with no edit. Human-facing docs name the 2-digit line, and the toolchain guarantees monotonic builds. See "Release Model". +- **Contracts state what, not how, and favor reuse.** [`WORKFLOW.md`](./WORKFLOW.md) fixes required outcomes, not a required implementation, so two repos may satisfy a guarantee with different YAML. Within that freedom, apply good engineering practice: minimize duplication and maximize reuse, which is why the pipeline splits a carried, generic orchestration layer from a repo-owned build layer. + +## Durable Knowledge and Self-Improvement + +- **Durable knowledge lives in the committed docs, not in agent memory.** Anything a future agent must honor (a rule, a contract, a hard-won gotcha, a pattern worth repeating or one to avoid) belongs in a committed governance file (`AGENTS.md`, `CODESTYLE.md`, `WORKFLOW.md`, or a committed backlog such as a `README.md` TODO section). Agent memory does not survive a new session, a new machine, or a new environment, so it holds only environment-specific nuance and in-flight session state, never anything whose loss on reset would matter. A durable lesson left only in memory is lost to the next agent. +- **Keep the governance current as you work.** When work surfaces something durable (a rule worth enforcing, a recurring gotcha, a positive pattern to repeat, a negative one to design out), record it in the governance docs as part of that change, rather than leaving it in a local note or routing around it with a one-off workaround. Where the governing doc is carried from a template this repo cannot edit directly, propose the change upstream instead of only fixing it locally. Governance is not static: it improves by agents folding good patterns in and designing bad ones out. + +This section keeps the full rules and is surfaced at its decision moment by the `agent-conduct` Skill at `.agents/skills/agent-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. + +## Repository Boundaries and Write Safety + +A state-changing GitHub call is the highest-blast-radius thing an agent does here: it runs under the maintainer's identity, so one wrong target writes to another owner's repository as the maintainer, an outward-facing and hard-to-reverse act. These rules bound every write (a git push, an API mutation, a comment, a label, a merge) on any platform, and they bound a write to a checkout on disk as well, since a blanket add or a hard reset in a working tree another task is using destroys work without ever reaching GitHub. Reads are unrestricted, and how far a local read can be trusted is governed under "Verification Discipline" rather than here. The bounds below are on writes. + +- **Write only within the owner of the current project's repository.** Every state-changing call targets this project's `origin` or another repository under the same owner, which is the fleet the maintainer already administers. A broad or logged-in identity is capability, not permission: a token that *can* reach another owner's repository does not authorize writing to it. Writing under a **different owner** needs explicit human permission naming that repository, granted deliberately rather than assumed from a token's reach, and a "harmless test" write is still a write, so there is no probe exception. That boundary is where the harm sits, since the incident this rule exists for was a stray comment on a stranger's repository, not work across the maintainer's own projects. Reads from anywhere are fine. +- **Never fabricate, guess, or reuse an identifier passed to a write.** Every id a state-changing call consumes (a node id, a numeric id, a thread or comment id) is captured from a live query in the **same** session into a variable and passed from there. Do not hand-type an id, guess it, recall it from memory or an earlier session, or copy it from documentation or an example. Ids commonly resolve **globally**, so a wrong-but-valid id does not fail. It writes to the wrong target, in someone else's repository. If a query returns no id, stop rather than invent one to proceed. +- **A write is never a probe, and a write's output is never suppressed.** Never fire a state-changing call to see whether it works: decide it should happen, make it happen, and read the result. Never append output-discarding redirection or a force-success tail to a mutation (for example `>/dev/null`, `2>/dev/null`, `&>/dev/null`, `|| true`, `|| :`, `|| echo`), because the write's output is exactly what must be read. A write that appears to fail is **verified, not assumed harmless**, because the operation may have succeeded on the server while the client reported an error, so confirm the actual state before retrying or moving on. The ban targets hiding a *failure*. An ad-hoc call's response is the only signal you get, so `>/dev/null 2>&1`, `|| true`, and `|| echo`, which swallow the error stream or force success, are never acceptable on one. A committed script under `set -e` is a narrow exception: it may send a write's *stdout* to `/dev/null` to drop the success-response noise, because stderr stays visible and a failed write still aborts loudly (the hub's own `repo-config/configure.sh` does exactly this, and a repository reaches it there rather than carrying a copy). The exception is stdout-only suppression inside a reviewed, fail-loud script, never `2>&1` or a force-success tail, and never an ad-hoc command. +- **A refused write is reported, never re-shaped, and the maintainer's say-so does not lift a refusal by the harness.** These are two different permissions and only one of them is the maintainer's to give. When the agent harness refuses a write, the maintainer authorizing it in conversation does not change the outcome, and the identical call is refused again, so a second attempt is not worth making and reading the second refusal as a flake is how an agent starts hunting for another shape of the same request. **That hunt is the failure this rule exists to stop.** Re-expressing a refused `gh` command as a raw `gh api -X POST` reaches the same endpoint with the same identity and the same blast radius, having defeated the one control that stopped it, and it is the more dangerous version because the agent believes it has permission. So a refused write is never re-attempted through a different API surface, a different tool, or a rephrasing, and it is never routed around by the agent writing itself a permission rule, which is self-authorization whatever the maintainer said. Two routes remain, both of them the maintainer's: they add the permission rule themselves, or they run the command themselves. Raise it as a blocked decision naming those two (see "Communicating with the User"), and where the work needs the result rather than the call, say what the agent will verify once the maintainer has run it. **A refusal is also a fact about the contract, not just about the session**: where a required verification can only be performed by a write the agent is refused, the document requiring it says so and names who runs it, since a check that is mandatory and unperformable is quietly dropped and then reported as done. +- **Each task runs in its own checkout, in its own directory, on its own feature branch.** The unit is the task rather than the agent, since one agent moving between two repositories meets the same hazard as two agents sharing one tree, and a rule written per agent permits exactly the case that goes wrong. The commands that cross the boundary are the ordinary ones rather than the reckless ones, and each is correct in isolation: a blanket `git add -A` sweeps another task's uncommitted work into the commit, a `git reset --hard` deletes it, and a branch switch carries it into an unrelated change. The mechanical habit that holds the rule up is that a mutating command takes an absolute path, or a `cd` to one in the same invocation, rather than the working directory it inherited, because a read in the wrong directory is a wasted call and a write there is damage. +- **A task isolates into its own worktree before its first file edit, and a continuation re-isolates.** All new work begins by creating a unique git worktree (or clone) on its own feature branch, based on the branch work starts on for the repository's model per "Branching Model", which is `develop` unless the task is explicitly about `main`-only content. The primary checkout is the maintainer's own surface, so a session launched there isolates before writing rather than after noticing contention, and a session resuming a prior task creates a fresh worktree rather than resuming wherever its branch happens to be checked out, since a branch sitting checked out in a shared tree is exactly how two sessions end up in one checkout. The moment this rule binds is the first file edit, because the commit-time and review-time checks all run after another task's uncommitted work can already be swept. The worktree mechanics, the layout convention, and the cleanup are packaged as the `repo-worktree` Skill at `.agents/skills/repo-worktree/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, and this section keeps the rule. +- **A checkout another task is live in is left rather than shared, and a footprint already left there is undone deliberately.** Two signals say someone else is in the tree, a branch that changes when nothing you did changed it, and an edit of yours reverted with no conflict, and the response to either is to stop rather than to re-apply the edit, which is the instinct and the wrong one. Leaving and cloning your own costs about a minute against an incident that costs the better part of an hour, so it is the cheap move rather than the cautious one. Once you have written there, leaving it alone arrives too late, so save your work aside, restore only the files you touched, verify the tree is clean, delete your branch from that clone, and then say plainly what was touched, since a regenerated report left behind reads as the other task's own and is committed by whoever runs the next blanket add. + +## Representative Data in Agent-Authored Text + +Agent-authored text illustrates with data the agent constructed, never with data it observed in the maintainer's environment. This binds every surface an agent writes: pull request and issue comments, review replies, commit messages, code, tests, fixtures, and docs. Reading real data is unrestricted, and what is bounded is what an agent copies out of the environment into text that is committed or posted. The rule holds for a private repository as much as a public one, since a repository's audience changes with one settings toggle while the text stays exactly where it was written, and it holds where the data is the maintainer's own, since the exposure happens on their behalf before they can weigh it. + +- **Synthetic evidence is the better evidence, not a weaker substitute.** A case constructed to carry the defect demonstrates it exactly and any reader can re-run it, where observed data proves the same thing and can never be reproduced by anyone else. A filename built to contain a newline is a complete proof of a newline-handling defect, and the real directory it was found in adds nothing the proof needed. Reaching for observed data to make a finding more convincing inverts which of the two is the stronger evidence. Where observed data is what revealed the defect, name its shape, meaning the property that triggers the fault, and construct a case that carries that property. +- **The exposure is one-way.** A public comment is fetched, cached, and indexed the moment it posts, so editing it afterwards is mitigation rather than a fix, and the edit leaves the original readable in the comment's edit history to anyone who can read the repository. Text that has already landed is reported to the maintainer rather than quietly rewritten, since the decision on what to do about it, deletion included, is theirs. Do not quote the exposed data again while reporting or investigating it, because a transcript, an issue, or a commit message written about the exposure reproduces it somewhere new. +- **No checker closes this.** A pattern finds an absolute home path or a drive letter, and gating that subset is worth doing as a floor. The exposure this rule exists for was name-shaped, and a name is not pattern-detectable, so a search of the offending text for path-shaped strings returns nothing while the names sit in plain sight. A gate here catches the easy half, and mistaking it for the answer is what stops anyone looking at the other half, which is why this is a judgment an agent applies rather than a check it waits for. + +## Git and Commit Rules + +The fleet's mechanical git rules: default to staging rather than committing, stage by explicit path only and never with a blanket add, commit means commit and push, every commit is signed and carries the committer's own verified GitHub `noreply` identity, never force push, a history rewrite re-identifies only the commits it touches that aren't yours, and destructive git commands run only on explicit instruction. + +This is packaged as the `git-commit-conventions` Skill at +`.agents/skills/git-commit-conventions/SKILL.md` in the hub, not a repo-relative link since that +path is hub-local and not carried into every fleet repo. The summary above sketches the contract. +Read the skill for the full rules. + +## Branching Model + +Two workflow models, set per repo by the registry `workflowModel` field. Most repos are +`release`: squash-only feature branches into `develop`, merge-commit-only `develop -> main` +promotions, forward-only with no back-merges, and two promotion traps worth knowing before the +first one (never delete `develop`, resolve an EOL-only conflict by taking `develop`'s side). +**GitHub's own "default branch" repository setting reads `main`, but `develop` is where work starts and where in-flight content lives**, so a worktree or clone that defaults to "the default branch" lands on `main` and can silently miss content already merged to `develop` but not yet promoted. Branch from `develop`, on either workflow model, unless the task is explicitly about `main`-only content. +**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 +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. + +## Release Model + +The **two-phase model is the default**: PRs build fast, publishing is batched, a human merge +never auto-publishes on its own. See [`WORKFLOW.md`](./WORKFLOW.md) for the full CI/CD contract. +Publishing fires on a manual dispatch, a code-affecting bot push to `main`, or (Docker only) a +weekly schedule, and versioning is semantic and maintainer-controlled (NBGV owns the build number, +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 +since that path is hub-local and not carried into every fleet repo. The summary above sketches +the contract. Read the skill for the full rules, including the release-target build layer, the +no-op republish guarantee, and wrapper-repo upstream-version tracking. + +## Operational Repositories + +The registry `workflowModel` field is `release` (the default) or `operational`. **Operational** +repos track a live service's running state rather than shipping versioned units of delivery +(live-service config such as Home Assistant, ESPHome, Vantage, and home automation): commits go +directly to `develop`, CI runs on the push as advisory feedback only, a PR still exists for a +change worth reviewing, the `main` promotion gate is unchanged, and release happens only by manual +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 +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. + +Line-ending governance for an operational repo is in [Line Endings](#line-endings), where its `[*]` default follows the consuming app's native platform per the registry `lineEndings` field, not the fleet LF default. + +## Repository Onboarding and Conformance + +Every fleet repo is a standard-style repo the hub audits **downward** against its declared type, the model the fleet uses because managing downstream divergence is too costly. Three obligations follow, and they are not optional: + +- **Definition of done is the audit.** Onboarding or materially changing a repo is complete only when it passes [`AUDIT.md`](./AUDIT.md) for its type, **or** carries a committed `reports/<repo>/audit.md` plus a tracking issue for the residual deltas. Do not leave a repo partially stood up and unrecorded, which is itself a defect. Stand a repo (or a new type) up with `STANDUP.md`, and verify it with [`AUDIT.md`](./AUDIT.md). The two share the same manifests, so a repo stood up correctly passes the audit by construction. A repo that is stood up already and has fallen behind the hub is brought back into line with `RESYNC.md`, which is the third procedure and the one a request to sync a repo with the hub means. It exists because a findings list does not state the order its remedies apply in, and one of them deletes a file rather than updating it. +- **The registry is ground truth about reality, not intent.** After any conformance change, reconcile the repo's `registry/repos.json` entry (`status`, `types`, `releaseTrigger`, `workflowModel`, `driftNotes`) to what the repo actually is. `spec/validate.py` proves the catalog is self-consistent. It does **not** prove the catalog matches the live repo. A `validate.py`-clean entry can still be false, and a stale entry is a finding. +- **Prove the docs suffice.** The onboarding docs are sufficient only when a context-free agent stands each supported repo shape (project type(s) plus workflow model) up from them alone. The cold-start self-test in `STANDUP.md` and the conformance matrix track that. A shape that cannot be stood up cold is a documentation defect, fixed in the hub, not worked around per repo. + +## Hub-Hosted Tooling + +The fleet's tooling lives in the hub once and a repository runs it from there rather than holding a copy. A carried script is current only until the next fix to it, and a repository that misses the sweep does not fail loudly, it audits itself with an older gate while reporting the same command in its output. Removing the copy removes the sweep, the stale-copy detection, and the disposition each stale copy earns, all at once. The hub is the repository [`AGENTS.md`](./AGENTS.md) "Fleet Bootstrap" names, and that section is the entry point whenever nothing else present says where it is. + +**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. + +**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. + +**A loader is outside this section rather than exempt from it.** The rule above governs a tool that reads hub content, because a tool reads the payloads, tables, and sibling modules beside it. A loader reads none of them: it obtains a tree and hands control to a tool inside that tree, on a host that cannot yet obtain one. The bound is what it may contain rather than who runs it, and it is one line: a loader references no path inside the tree it fetches except the single entry point it hands control to, and everything else it touches is the machine or the network. A loader that grows a second path into that tree has become a tool and is governed above. + +**A report or finding a hub tool produces names the hub commit it ran from.** The tool moves independently of the repository it measures, so a verdict carrying no hub commit cannot be re-run, and two runs that disagree cannot be attributed to the tree or to the tool. The obligation is the runner's rather than the tool's, since a tool reports on the repository it measures rather than on itself, so the commit is read from the hub checkout and written into the report beside the verdict. This is the same requirement "Verification Discipline" places on any claim that gets acted on. + +**CI reaches the same tooling as a pinned action or reusable workflow.** A runner holds no hub checkout, so a workflow consumes the hub's composite action or reusable workflow and pins it to a commit SHA, per the action-pinning rule under "Workflow YAML Conventions". A standard workflow whose job graph is identical across repos of a type is reached the same way, as a `workflow_call` task the hub hosts once, and the repository carries only the caller stub and a composite-action hook for what is genuinely its own. The pin is what makes a released repository's gate reproducible, since an unpinned consume lets a later hub commit fail a re-run of a change that already passed. Branch-dependent behavior belongs inside the consumed action, because `uses:` takes no expressions and a per-branch ref therefore cannot be selected in the workflow file. + +**An unreachable hub means the tool did not run, and that is the result reported.** A carried copy still works offline and a reached one does not, which is the cost this model trades away and the reason to state the failure rather than route around it. A check that cannot run reports itself as not run, never as clean, which is the silent-narrowing failure "Verification Discipline" names. A hand-rolled substitute is not the tool either: a reconstructed gate encodes its author's reading of the rule rather than the rule, agrees with no other repository, and is the duplicated effort this model exists to end, so an agent that cannot reach the hub says so and stops. + +## Pull Request Title and Commit Message Conventions + +A PR title and a commit message share one contract: an imperative subject, 72 characters or fewer, no trailing period, no vague titles like `update stuff` or `wip` (Dependabot's `Bump X from Y to Z` is fine as-is), no unsolicited `Co-Authored-By:` lines, and no release-bump magnitude in the title, since Nerdbank.GitVersioning computes the next version from `version.json` and git history. + +This is packaged as the `comment-and-doc-style` Skill at `.agents/skills/comment-and-doc-style/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. The summary above sketches the contract. Read the skill for the full format, rules, and examples. + +## 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. + +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. + +### Comments + +The full comment philosophy, what earns one, structure, capitalization, growth discipline, is in the `comment-and-doc-style` Skill referenced above. + +### 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. + +### Line Endings + +The full CRLF/LF policy (`.editorconfig` and `.gitattributes` defaults and pins, choosing an ending for a new file type, operational-repo overrides, editing discipline, and auditing) is in the `comment-and-doc-style` Skill referenced above. + +### Sentence Structure + +ASD-STE100's structural half is the adopted house style: short sentences, one instruction per sentence, active voice, and imperative mood for procedure steps. Its controlled dictionary is deliberately not adopted. The full rules, the sentence word cap, and the opt-in `sentence-length` check that enforces the cap are in the `comment-and-doc-style` Skill referenced above. + +## Verification Discipline + +The checks that separate work actually done from work that merely reports success. Their unifying property: **every failure below is green.** A skipped job and a passing job are indistinguishable in the aggregated required check, a pattern that matches less still exits zero, and a gate that stops gating still reports success. No linter, status check, or review layer catches any of them. + +- **Locate every check a change owes before running any of them, and CI's coverage is not that list.** The checks are read from what the repository declares, meaning its [`OPERATIONS.md`](./OPERATIONS.md) "Local Verification" section alongside the workflows, rather than inferred from whatever the pipeline happens to run. Part of a repository's contract is routinely unreachable from a runner, a redirect no build serves, a deploy no pull request performs, hardware no runner holds, so the check covering that part lives in a document rather than in a workflow and is run by hand before the pull request opens. Green is then the precise signal that it was skipped, because the pipeline reports success over the half it reaches while saying nothing about the half it cannot. Reading a document's own description of itself is not how such a check is found, since a topical document is named for its most visible function, usually a post-merge one, and an accurate description of that function routes a pre-merge task away from the file holding the gate. The destination is declared fleet-wide for that reason, so finding the check never depends on how well one repository worded a pointer to it. +- **A test must assert the mechanism it names, and a gate has to be watched failing.** Label each case by the behavior it proves, then write the case that reintroduces the fault and confirm the gate objects to it. A case that passes for an incidental reason, the right answer reached by the wrong path, is worse than no case, because it is later cited as evidence. A proof that restates the gated data instead of reading it proves only that the function works, so drive the real table or the real config. And a gate that finds nothing is indistinguishable from a gate with nothing to find, so assert a floor on what a healthy run covers. +- **Gates, filters, and gate-like watchers fail loud, never narrow quietly.** A pattern that silently matches less, an allowlist that silently stops matching, or a gate that silently stops gating all report success while doing nothing. When a construct exists to notice something, make the not-noticing case produce an error or an annotation. An identity allowlist used as a gate, for one, must raise an error when its list stops matching, not silently pass everything through. +- **Config with a uniqueness rule is validated on read, and its consumers assert what it promised.** A repeated key in a lookup table is not a precedence question to settle quietly, it is two answers to one question, and keeping whichever came last picks one of them where the reader sees no choice being made. Fail on the duplicate at the point the config is read, so the code downstream can rely on the invariant instead of re-deriving it. +- **Validate and read on the same normalized key.** A guard that compares stripped names while the join looks up the raw one passes a padded key and then matches nothing, so the exact fault the guard exists to stop is sitting inside the guard. Normalize once at the boundary and use that one value for both the check and the lookup. +- **Run the repo's whole lint gate before every push, not the parts that look relevant.** CI runs all of them, so a partial local run only defers the failure, and the tool most likely to catch a given change is often the one it seems least about (an edit that manipulates line endings is exactly when `editorconfig-checker` matters). The repo documents each linter's known-working invocation, and this rule is that **all** of them run. +- **Editing CRLF files programmatically: `.` matches `\r` in a regex**, so a captured line keeps its carriage return and rejoining with `\r\n` yields `CRCRLF`. A text-mode rewrite has the mirror failure, silently flattening CRLF to LF. Prefer line-based edits (`splitlines(keepends=True)`) or literal replacement over regex reassembly. In Python the text-mode failure is the *default*: `Path.read_text()` decodes through universal newlines and `write_text()` writes `\n` back, so a read-edit-write round trip flattens the whole file while the edit itself looks correct. Pass `newline=''` to both, or work in bytes. This is the mechanism behind the Line Endings warning above, and it is worth naming because the corruption is invisible in a rendered diff. +- **Scope a check by what the project declares, not by the file that prompted it.** A check written while editing one file tends to cover that file's language and stop, and then reports success on every other surface the rule governs. Read the declared types, or the config that enumerates them, and cover each one, then assert a floor per surface so a table that narrows fails loudly instead of passing quietly. A rule about comments means every comment syntax the project ships, and a format that carries comments in practice counts even where its specification says otherwise. +- **Never edit source through a shell heredoc when the text carries backslash escapes.** The shell consumes the escape and writes an invisible control character in its place, so a `\b` inside a regex becomes a backspace and the pattern silently matches nothing while every test still passes. Use a file-editing tool for such text. When a check inspects text for control characters, use `str.isprintable()` rather than a codepoint floor, since DEL and the Unicode format characters sit above 32 and are equally invisible in a diff. +- **Never edit an active `.code-workspace` file.** A workspace file rewritten on disk can make VS Code reload the window, and a reload destroys the running agent session's context, so the work in flight is lost with nothing to catch it, and the trigger is not fully characterized (an agent's edit has caused the reload where a human's identical edit did not). Surface the needed change for the maintainer to apply by hand. +- **A green check is not evidence the work happened.** A skipped job and a passing job are indistinguishable in the aggregated required check. When a job exists to exercise something, confirm from its log that it ran and produced the output it promises. +- **A local clone is not the branch it names, it is whatever that clone last fetched.** Reading a checkout on disk answers what that clone last saw, so a finding taken from one carries a date nobody stated, and two failures of exactly that shape are on record from one session: a repository reported as still drifted on a file whose fix had already merged, and a repository reported as missing a file it carries because the checkout sat on an older branch. Read the live ref through the API where the claim will be acted on, or fetch immediately before reading, and name the ref and the commit in any finding a local read produced. A clone stays the right tool for anything needing history or a build, which an API read cannot give. +- **A "does not exist" claim names the branch it was checked against.** A worktree or checkout answers for whichever ref it was built from, and that ref is not necessarily the one the content lives on: a `release`-model repo carries in-flight content on `develop`, per "Branching Model" above, well before it reaches `main`, so a worktree defaulted to the fleet's default branch can hold nothing while the repository holds everything. Before reporting a file, a directory, or a piece of content as absent anywhere in a repo, check it against the branch the repo's own model designates as current for that kind of content, not only whichever branch a worktree or checkout happened to default to, and name the branch the negative claim was checked against in the finding itself. +- **A launched process is not a result, and a cause nobody observed is not a diagnosis.** "The watcher is armed" names a process rather than a finding, so what gets reported is the output that process produced, and where it produced none, that absence is the report. The failure it prevents is an agent standing still on a condition that was met half an hour earlier, having announced the wait and never read it. Naming an external cause for such a stall afterwards, a throttle or a quota that appears nowhere in the record, turns a local defect into a story about someone else and closes the investigation on the wrong party, so read the record for the cause before naming one, and where the record does not carry it, report the cause as unknown. +- **A workflow change is only fully exercised by CI.** Extracting a `run:` block and executing it locally validates the script and nothing else, because `secrets: inherit`, `permissions:`, `needs:` wiring, and reusable-workflow inputs resolve only in a real run. +- **Platform-specific code is "verified" only on the platform it runs on.** PowerShell on Windows, a macOS-only `mktemp`/`ssh-agent` behavior, a WSL-specific path quirk: an agent reasoning about such code from a different host, however carefully, has not executed it, and reasoning by structural analogy to an already-tested equivalent on another platform ("the POSIX version works, so the PowerShell version should too") is a plausible first pass, not verification. State it as exactly that, an unverified structural match, and never in the same words used for a tested fact. When no agent in the loop has access to the target platform, say so, and either defer the platform-specific portion to a human or an agent that has that access, or ship it clearly labeled unverified. +- **A review flags an instance, so fix the class.** When a reviewer cites one stale claim, one silent-narrowing pattern, or one mis-worded contract, sweep for its siblings before replying. Reviewers sample rather than enumerate. + +This section keeps the full rules and is surfaced at its decision moment by the `agent-conduct` Skill at `.agents/skills/agent-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. + +## PR Review Etiquette + +The provider-agnostic review-loop contract every fleet repo follows: 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 the low-confidence ones a review body collapses rather than threads, and reply and resolve. Never merge on a green or CLEAN merge state alone, since that field 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 finding, which opens no thread at all, unanswered. + +This is packaged as the `pr-review-conduct` Skill at `.agents/skills/pr-review-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. The summary above sketches the contract. Read the skill for the merge gate, the expected loop, and how a finding is closed. + +The provider-specific mechanics this contract needs to actually drive GitHub Copilot, how to request a review, poll for it, match the suppressed-findings heading, verify coverage, and reply or resolve a thread without a hand-typed id, live in [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) "GitHub Copilot Review Runbook", unaffected by this pointer. That content is read directly by the Copilot reviewer bot, which cannot read a Skill, so it stays fully written out rather than being packaged the same way. + +## 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. + +This section keeps the full rules and is surfaced at its decision moment by the `agent-conduct` Skill at `.agents/skills/agent-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. + +## Workflow YAML Conventions + +These conventions describe the target state. New and modified workflows must respect them. The rest of the repo is expected to be brought up to the same standard. Sweep PRs that apply a rule everywhere are welcome when a rule changes. + +This section and [`WORKFLOW.md`](./WORKFLOW.md) keep the full rules, this section winning where the two overlap, and both are surfaced by the `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. + +- **Action pinning**: pin **every** action, first-party (`actions/*`) and third-party alike, to a commit SHA with a trailing `# vX.Y.Z` comment, so Renovate / Dependabot can still bump it but a tag swap can't change the executed code. Use `# vX` (major-only) only when the upstream's floating major tag doesn't correspond to a specific patch/minor release SHA, since pinning to the floating-tag SHA still gives the SHA guarantee, the version comment just records the major line. Documented exception (no SHA pin at all): `dotnet/nbgv` is consumed via `@master` because the upstream tag stream lags `master` substantially and Dependabot's tag-tracking would propose a downgrade. **This applies to repo-owned build-layer leaves too**, since a leaf owning its build specifics is not a reason to use floating tags, and Dependabot still bumps SHA pins (updating the SHA + version comment). +- **Filename**: reusable workflows (those with `on: workflow_call`) end in `-task.yml`. Entry-point workflows (`on: push` / `pull_request` / `schedule` / `workflow_dispatch`) do NOT use the `-task` suffix. They end with what they do: `-pull-request.yml`, `-release.yml`, etc. The suffix carries semantic meaning: a `-task.yml` file is meant to be `uses:`-d, never triggered directly. +- **Workflow `name:`** (the top-level `name:` field): reusable workflow names end in **"task"** (e.g. `Build PyPI library task`), and entry-point workflow names end in **"action"** (e.g. `Publish project release action`, `Test pull request action`). The displayed action name in the GitHub Actions UI tells you at a glance whether you're looking at an orchestrator or a callee. +- **Job and step `name:` suffixes**: every job's `name:` ends in **"job"** and every step's `name:` ends in **"step"**, including the PR-gate aggregator, whose `name:` is a required-status-check `context:` in a branch ruleset (`Check pull request workflow status job` in `test-pull-request.yml`). A ruleset-bound job's `name:` and its ruleset `context:` are the **same string**: rename them **together**, updating the live ruleset and `repo-config/{develop,main}.json` in lockstep with the job `name:`, never one without the other, or required-status-check enforcement silently breaks. There is no un-suffixed exception. +- **Concurrency**: top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }` so a fresh push supersedes an in-flight run on the same ref. **Documented exceptions** (both record the rationale inline in their header comment): (1) [`merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml) uses `cancel-in-progress: false` because the merge-bot's job model (enable-auto-merge on opened, disable-auto-merge on maintainer-pushed synchronize, with method dispatched by base) requires each event to run to completion in arrival order, because cancellation would leave auto-merge in an inconsistent state. (2) [`publish-release.yml`](./.github/workflows/publish-release.yml) uses both a **global, ref-independent group** (`group: ${{ github.workflow }}`, dropping the usual `-${{ github.ref }}`) and `cancel-in-progress: false`. It publishes shared ref-independent artifacts (both branches' Docker tags/caches and GitHub releases) on schedule/dispatch regardless of the triggering ref, so a ref-scoped group would let a scheduled run (ref `main`) and a manual dispatch (ref `develop`) run concurrently and double-push, and cancelling a publish mid-flight can leave a partially pushed tag set or a half-created release. The global group + queueing serializes every publish run to completion. +- **Shells**: every bash surface, a multi-line `run:` block and every committed `.sh` script alike, starts with `set -Eeuo pipefail`: fail fast, fail on undefined vars, fail on a failed pipe segment, and let an `ERR` trap inherit into functions, subshells, and command substitutions (`-E`). The `-E` is defense in depth: the fleet ships no `ERR` trap today, so a script that later adds one inherits the behavior instead of silently losing it. +- **Conditionals**: multi-line `if:` uses folded scalar `if: >-` so YAML preserves whitespace correctly. Literal block (`if: |`) is wrong because it embeds newlines inside the boolean expression. +- **Boolean inputs**: workflows triggered both via `workflow_call` and `workflow_dispatch` must declare each boolean input in *both* trigger blocks, since one definition does not propagate to the other. `workflow_call` delivers booleans as actual booleans, and `workflow_dispatch` delivers them as the *strings* `"true"`/`"false"`. Any `if:` consuming a boolean input must compare against both forms: `if: ${{ inputs.foo == true || inputs.foo == 'true' }}`. +- **Validate input/state consistency at entry, fail fast**: when a workflow's inputs must satisfy a cross-input or input-versus-derived-state invariant (e.g. the release branch must match the computed version's prerelease status, or two inputs are mutually exclusive), assert it **once** in a dedicated entry validation step/job that the downstream jobs `needs:`, before any expensive build or publish work, not as partial checks scattered deep in later jobs. One gate that fails fast with a clear `::error::` beats a late or one-directional check. Examples: `build-release-task.yml`'s `validate-release` job (branch-versus-prerelease, both directions) and `publish-docker-readme-task.yml`'s "Validate inputs step". +- **Reusable workflows**: job-level `permissions:` are validated *before* the `if:` evaluates, so even a skipped job needs valid permissions declared. A `release` job with `permissions: contents: write` and `if: ${{ inputs.publish }}` will still cause `startup_failure` on a caller that doesn't grant `contents: write`. Either declare permissions at the call site, or omit the inner block and inherit. +- **Allowlist `success` and `skipped` explicitly** when chaining jobs across optional dependencies, since `!= 'failure'` lets `cancelled` through (timeout, runner failure, manual cancel). Use `(needs.X.result == 'success' || needs.X.result == 'skipped')`. +- **Artifact retention**: workflow artifacts are an intra-run handoff only, with durable copies living on the GitHub release rather than in workflow artifacts, so they must not survive the run and accumulate against the small account-wide artifact-storage quota. **Clean up each transfer artifact surgically at its point of consumption**: the job that downloads it deletes it by exact name/pattern right after consuming it (the `github-release` job deletes `release-asset-<branch>-*` after attaching them to the release, and `publish-release.yml`'s `publish-pypi` deletes `pypilibrary-build-<branch>` after publishing). Deletion needs `actions: write` granted on that job, and for a reusable callee (e.g. `github-release` inside `build-release-task.yml`) the **caller** grants it (`publish-release.yml`'s `publish` job does). **Never blanket-delete the run's artifacts** (`gh api .../artifacts --jq '.artifacts[].id'`). That also destroys diagnostic/log artifacts and the build-records actions emit automatically (`docker/build-push-action`'s `.dockerbuild`), which are exactly what you need to debug a failed run. Set `retention-days: 1` on **every** explicit `upload-artifact`: it is the failure-path backstop, since a job that dies before its consumer runs leaves its artifact to be reaped within a day, so no separate terminal cleanup job is needed. A repo customizing these jobs must preserve the consume-then-delete shape. +- **Docker layer cache**: cache to/from a registry tag (`type=registry`, e.g. `buildcache-<branch>` on Docker Hub), not the GitHub Actions cache (`type=gha`), to keep large image layers off the 10 GB Actions cache. A **multi-image** repo uses a **per-image** buildcache tag (`<repo>:buildcache-<branch>` for each image, plus the base image's own tag and inline cache). It does not fall back to `type=gha` for the extra images. +- **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. + +**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), and, **for a repo that carries `.ps1` files**, **PSScriptAnalyzer** the same way (it has no action either). 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. +- **The `.husky/pre-commit` hook** runs **language formatting** and the **diff-scoped doc gates**, never Docker and never a network call, so it stays fast. The formatting half is whatever the repo's own language needs, CSharpier and `dotnet format` for .NET or ruff 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 what the commit changes 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. 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, since it resolves a same-owner pin against the GitHub API and a hook that needs a network fails offline. A repo enables the hook per clone with `git config core.hooksPath .husky`, and 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. + +The Docker invocations below are the same ones the VS Code tasks use, for ad-hoc or headless (agent) runs. + +- **editorconfig-checker** (line endings + charset across the tree): + + ```sh + docker run --rm --pull=always -v "$PWD":/check --workdir /check mstruebing/editorconfig-checker:latest + ``` + +- **actionlint** (GitHub Actions workflow YAML, run after any `.github/workflows/` edit, since workflow-only changes are not smoke-built): + + ```sh + docker run --rm --pull=always -v "$PWD":/repo --workdir /repo rhysd/actionlint:latest -color + ``` + + The `rhysd/actionlint` image bundles `shellcheck`, so it also validates `run:` shell blocks. The direct-binary/curl-installer path is often sandbox-blocked, so use Docker. + +- **markdownlint-cli2** (Markdown, mirroring the davidanson VS Code extension via the shared [`.markdownlint-cli2.jsonc`](./.markdownlint-cli2.jsonc), so the CLI and IDE agree): + + ```sh + docker run --rm --pull=always -v "$PWD":/workdir --workdir /workdir davidanson/markdownlint-cli2:latest "**/*.md" + ``` + +- **cspell** (spelling in user-facing docs, with the word list and exclusions in [`cspell.json`](./cspell.json)): + + ```sh + docker run --rm --pull=always -v "$PWD":/workdir --workdir /workdir ghcr.io/streetsidesoftware/cspell:latest --no-progress README.md HISTORY.md + ``` + +- **PSScriptAnalyzer** (PowerShell, the peer of the shellcheck step, **only applies to a repo that carries `.ps1` files**, which carries `PSScriptAnalyzerSettings.psd1` alongside them with the excluded rules and their reasons): + + ```sh + docker run --rm --pull=always -e PS_SCRIPTS="$(git ls-files '*.ps1')" -v "$PWD":/mnt --workdir /mnt mcr.microsoft.com/powershell:latest \ + pwsh -NoProfile -Command ' + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module PSScriptAnalyzer -RequiredVersion 1.23.0 -Force -Scope AllUsers + Import-Module PSScriptAnalyzer + $files = $env:PS_SCRIPTS -split "\s+" | Where-Object { $_ } + if (-not $files) { Write-Host "no PowerShell scripts are tracked"; exit 0 } + $found = @() + foreach ($file in $files) { $found += Invoke-ScriptAnalyzer -Path $file -Settings ./PSScriptAnalyzerSettings.psd1 } + Write-Host "Checked $($files.Count) file(s)" + if ($found) { $found | Format-Table RuleName,Severity,ScriptName,Line,Message -AutoSize | Out-String -Width 200 | Write-Host; exit 1 } + Write-Host "no findings" + ' + ``` + + The module version is pinned beside the image, because the image alone does not fix it and a floating install makes a local run a different check from CI. 1.23.0 rather than the newest, since 1.24.0 needs a newer `System.Management.Automation` than the image carries and fails to import after installing cleanly. The file list comes from `git ls-files` for the same reason the shellcheck step uses it, and the count is printed because a run that read no files reports the same clean as one that read them all. + + **The list splits on whitespace rather than on a newline, and the regex is double-quoted.** A shell joins the file list with newlines and PowerShell joins it with spaces, so a newline-only split hands the analyzer one path holding every file, which it reports as one file it cannot find followed by a clean run over nothing. The double quotes are what let the whole invocation stay inside the single-quoted `-Command` a shell passes, since PowerShell escapes with a backtick and leaves the backslash alone. Run verbatim it reports `Checked 5 file(s)` from either shell. + + In a configured editor the davidanson extension is enough. Use the Docker CLI when there's no IDE (agent/headless) or to confirm a clean run 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. +- **A repo's platform ceiling is set by its dependencies, not by tooling effort, so decide it per repo before writing dev tooling.** Narrow below the default only for a hard runtime ceiling, where the code can only execute or test on one platform (e.g. a Home Assistant integration is Linux-only: HA Core has POSIX-only dependencies and will not run natively on Windows, so even maximal tooling yields only lint-only there). The narrowing axis is where code *executes* for dev and testing (native, SSH-remote, container, or CI), never where editing happens. +- **Record a narrowed platform and its reason in the repo** (README/AGENTS) so the restriction reads as a deliberate dependency ceiling, not an omission. + +## Devcontainer + +Contributors commit to this repo with signed commits. The SSH-signing setup lives in [docs/ssh-signing.md](./docs/ssh-signing.md), host prerequisites in [docs/host-setup.md](./docs/host-setup.md), and devcontainer SSH-agent forwarding in [docs/devcontainer.md](./docs/devcontainer.md). This repo ships no application toolchain. The per-language devcontainer definitions it once used are kept as reference under [`catalog/snippets/devcontainer/`](./catalog/snippets/devcontainer/). + +## Editor and Tasks + +- **VS Code is the primary IDE, and the experience favors it.** Prefer VS Code tasks and launch configurations for building, running, and testing over ad-hoc shell scripts. A script is the fallback, not the default. +- The `.code-workspace` file carries the shared editor settings and the recommended-extension set. **All VS Code settings and extension recommendations live only here, never in a standalone `.vscode/settings.json` or `.vscode/extensions.json`** (`.vscode/` holds only `tasks.json` and `launch.json`). A **standard set** of extensions applies to every repo (markdownlint, cspell, editorconfig, markdown-all-in-one, better-todo-tree, github-actions, actionlint, shellcheck, claude-code); **language-specific** extensions are added per project (.NET: csdevkit, csharpier; Python: python, pylance, ruff, mypy; Docker: the Docker extension). +- The Table of Contents is maintained by the Markdown All in One extension, and `markdown.extension.toc.levels` in the workspace sets which heading levels it includes (see the Markdown rules for the authoring convention and the `<!-- omit from toc -->` exclusion marker). +- **Agents: editing the active `.code-workspace` can reload the VS Code window and drop the agent's session.** Commit all state first, prefer opening the folder rather than the workspace while editing it, or leave workspace edits to the maintainer (a maintainer edit does not reload). + +## Repository Details + +Every repo's GitHub repository details (the About panel) follow a fixed convention so the fleet stays consistent and self-describing. + +- **Description** matches the README's **tagline**, its first non-empty line after the `#` H1 heading, as plain text, stripping Markdown links (`[text](url)` and `[text][ref]` become `text`) since a description is not rendered. It is that one line and not the paragraph it opens: a README may carry further paragraphs below the tagline, and no mirror reads them. The README is the source of truth: set the description from it (`gh api -X PATCH repos/<owner>/<repo> -f description=...`), never the reverse. When the current description is *more specific* than the README (a chip revision or variant the README omits), surface the drift to the maintainer rather than silently discarding the detail, and the fix is to sharpen the README so the description follows it. Keep the line at most **100 characters**, Docker Hub's short-description cap and the tightest surface it feeds. For a repo that publishes a Docker image, the **Docker Hub short description** mirrors the same tagline, so one canonical sentence carries to the README, the About panel, and Docker Hub alike. Docker Hub receives it from the About panel, which the docker-readme task reads at publish time, so an About panel left diverged from the README is carried onward rather than corrected there. +- **Topics** are optional, and any that are present match the repo's actual content. Do not invent topics to fill the field. +- **Include in the home page**: Releases on, Deployments off, Packages off. These toggles are UI-only, since the REST and GraphQL APIs neither read nor write them, so they are set by hand and cannot be audited through `gh`. + +## Repository Layout + +- [`AGENTS.md`](./AGENTS.md): the agent entry point, carrying context and delegation rules plus the map to the sections below. +- [`GOVERNANCE.md`](./GOVERNANCE.md), [`CODESTYLE.md`](./CODESTYLE.md), [`WORKFLOW.md`](./WORKFLOW.md), [`AUDIT.md`](./AUDIT.md): the governance and audit docs. This file is the cross-cutting-rules authority. +- [`spec/`](./spec/): the machine-readable ground truth, covering project-type requirements, the file/section baseline, secrets, the host tool contract and its floors, and the preferred README structure. +- [`registry/repos.json`](./registry/repos.json): the fleet registry, holding every project, its type(s), and its status. +- [`repo-config/`](./repo-config/): branch rulesets, the fleet settings, and the apply script every repo is configured by, kept out of `.github/` (which is Actions-owned). The payloads carry to the fleet and the script is reached here. +- [`catalog/`](./catalog/): reusable reference snippets (workflows, configs, devcontainers) the audit compares implementations against. +- [`scripts/`](./scripts/): the fleet's lint and review tooling, with the deterministic checks also gating CI, for the documented rules no CI linter checks. Its unit tests sit apart under [`scripts/tests/`](./scripts/tests/). Hosted here and reached rather than carried, per "Hub-Hosted Tooling". +- [`docs/`](./docs/): hub-only procedure and measurement docs (host setup, config carry, signing, agent token cost), none of them carried downstream. +- [`reports/`](./reports/): per-repo audit output. +- [`.github/workflows/`](./.github/workflows/): this repo's own lint-only CI and source-only release, the workflow contract it dogfoods. + +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. diff --git a/HISTORY.md b/HISTORY.md index f250dfcc..9a9fd335 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,10 +1,10 @@ -# ProjectTemplate - -Agent enablement for a fleet of repositories: autonomy and repeatable quality inside guardrails. - -## Release History - -- Version 2.0: - - Repurposed from a .NET sample-project template into a governance and workflow-audit catalog: the shared fleet rules (`AGENTS.md`, `CODESTYLE.md`, `WORKFLOW.md`), a machine-readable `spec/`, the fleet `registry/`, per-repo audit `reports/`, branch rulesets, and three procedures an agent routes between by what a repository actually holds: `STANDUP.md` from nothing to operational, `AUDIT.md` to measure without changing anything, and `RESYNC.md` to bring a repository that has fallen behind back into line. Adds a host tool contract with version floors (`spec/host-tools.json` and a carried `host-tools.json` per repository), and a derived detector for files the hub hosts rather than carries, so a retired file is found rather than remembered. Ships no application code, and the old sample project and its build pipeline are removed. -- Version 1.0: - - .NET sample-project template with a build and publish pipeline. +# ProjectTemplate + +Agent enablement for a fleet of repositories: autonomy and repeatable quality inside guardrails. + +## Release History + +- Version 2.0: + - Repurposed from a .NET sample-project template into a governance and workflow-audit catalog: the shared fleet rules (`AGENTS.md`, `CODESTYLE.md`, `WORKFLOW.md`), a machine-readable `spec/`, the fleet `registry/`, per-repo audit `reports/`, branch rulesets, and three procedures an agent routes between by what a repository actually holds: `STANDUP.md` from nothing to operational, `AUDIT.md` to measure without changing anything, and `RESYNC.md` to bring a repository that has fallen behind back into line. Adds a host tool contract with version floors (`spec/host-tools.json` and a carried `host-tools.json` per repository), and a derived detector for files the hub hosts rather than carries, so a retired file is found rather than remembered. Ships no application code, and the old sample project and its build pipeline are removed. +- Version 1.0: + - .NET sample-project template with a build and publish pipeline. diff --git a/LICENSE b/LICENSE index c07453d5..51539ce9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,21 @@ -MIT License - -Copyright (c) 2026 Pieter Viljoen - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +MIT License + +Copyright (c) 2026 Pieter Viljoen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/OPERATIONS.md b/OPERATIONS.md index 60141f5c..d4ab392e 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -1,125 +1,125 @@ -# Operations - -How this repository is run. It ships no application code, so its operations are the fleet audit, the gates that mirror CI, and the script that applies repository configuration. Those gates and that script serve the whole fleet from this checkout rather than being carried into each repository, per [GOVERNANCE.md "Hub-Hosted Tooling"](./GOVERNANCE.md#hub-hosted-tooling), so every run below is a run from here against a repository named on the command line. - -## Local Verification - -What verifying a change here requires, including the part CI cannot perform. The gates below do run in CI, so a local run of them buys an earlier failure rather than a different one. The two verifications CI never performs are the fleet audit and the repository-configuration check, because each reads live state in another repository over the API and a pull request runner is given neither the credentials nor a target to read. `python3 spec/audit.py --selftest` is what CI runs of the audit, which exercises the checker against its fixtures and reads no repository at all. So a change to [spec/](./spec/), [registry/](./registry/), [repo-config/](./repo-config/) or [AUDIT.md](./AUDIT.md) is verified by running `python3 spec/audit.py` and `repo-config/configure.sh check` from this checkout before the pull request opens, each from the repository root and each taking the arguments the two runbooks below give it. A green pipeline says nothing about either, and taking it as coverage is the failure this section exists to name. - -### Run the gates the way CI runs them - -CI passes explicit `--check` lists, and a bare `python3 scripts/prose_lint.py [file]` runs `DEFAULT_RULES`, which is those two lists together. What differs is the exit code rather than the coverage. CI gates on nine of the ten and reports `charset-unknown` warn-only, where a bare run exits non-zero on any of the ten. `sentence-split` and `sentence-length` are in neither, so nothing below runs them and a local run reaches them only by naming them. Run the CI invocations: - -```sh -uvx ruff@latest check . -uvx ruff@latest format --check . -uvx mypy@latest -uvx coverage@latest run --source=scripts,spec,host-setup -m unittest discover -s scripts/tests -uvx coverage@latest run --source=scripts,spec,host-setup --append spec/audit.py --selftest -uvx coverage@latest run --source=scripts,spec,host-setup --append host-setup/agent-safety/gh-write-guard.py --selftest -uvx coverage@latest run --source=scripts,spec,host-setup --append host-setup/agent-safety/test_install.py -uvx coverage@latest report -python3 scripts/build_dist.py --check -python3 scripts/repo_gate.py -python3 scripts/prose_lint.py . --check charset --check semicolon --check dash --check dupword --check spelling --check comment-wrap --check comment-case --check home-path --check dead-path -python3 scripts/prose_lint.py . --check charset-unknown --summary -for f in registry/*.json spec/*.json repo-config/*.json; do jq empty "$f"; done -python3 spec/validate.py -docker run --rm --pull=always -v "$PWD":/check --workdir /check mstruebing/editorconfig-checker:latest -scripts=(); while IFS= read -r f; do scripts+=("$f"); done < <(git ls-files '*.sh'); docker run --rm --pull=always -v "$PWD":/mnt --workdir /mnt koalaman/shellcheck:stable "${scripts[@]}" -docker run --rm --pull=always -e PS_SCRIPTS="$(git ls-files '*.ps1')" -v "$PWD":/mnt --workdir /mnt mcr.microsoft.com/powershell:latest pwsh -NoProfile -Command 'Set-PSRepository PSGallery -InstallationPolicy Trusted; Install-Module PSScriptAnalyzer -RequiredVersion 1.23.0 -Force -Scope AllUsers; Import-Module PSScriptAnalyzer; $files = $env:PS_SCRIPTS -split "\s+" | Where-Object { $_ }; if (-not $files) { Write-Host "no PowerShell scripts are tracked"; exit 0 }; $found = @(); foreach ($f in $files) { $found += Invoke-ScriptAnalyzer -Path $f -Settings ./PSScriptAnalyzerSettings.psd1 }; Write-Host "Checked $($files.Count) file(s)"; if ($found) { $found | Format-Table RuleName,Severity,ScriptName,Line,Message -AutoSize | Out-String -Width 200 | Write-Host; exit 1 }; Write-Host "no findings"' -``` - -The `test_install.py` line behaves differently here than in CI, stated so its failure reads as the verdict it is. Its report cases install from this checkout and assert the machine then reads as current. An install from a checkout carrying uncommitted changes records a dirty stamp that reads as stale. So on a working tree mid-change those cases fail by design where CI's clean checkout passes. The remedy is to run them again once the change is committed, not to read the failure as a regression. - -The two container lines that take a file list differ from the workflow in **form** and not in what they check, and both differences exist because this runbook runs on a developer's machine where the workflow runs on `ubuntu-latest`. The shell list is collected with a `while read` loop rather than the workflow's `mapfile`, since `mapfile` arrives in bash 4 and macOS ships 3.2, and it stays an array so a path carrying whitespace is still passed as one argument. The PowerShell list splits on whitespace rather than on a newline, since a shell joins `git ls-files` output with newlines and PowerShell joins it with spaces, and splitting on the newline alone hands the analyzer one argument holding every path, which it reports as one file it cannot find and a clean run over nothing. - -Two gaps in that list are CI's rather than this runbook's, reproduced here so a local run matches CI rather than quietly exceeding it. The `jq` glob covers `repo-config/*.json` and does not reach `repo-config/operational/develop.json`, so a malformed operational payload passes. The second is that `sentence-split` and `sentence-length` are implemented and tested but named by no invocation, so nothing runs them. - -Run the `editorconfig-checker` line before pushing a new file, and before pushing an existing file that a script rewrote rather than an editor. This repository defaults to CRLF and most tooling writes LF, so a new file fails that check on its first CI run rather than locally. A scripted rewrite is the same hazard on a file that was already correct, since reading and rewriting a whole file in text mode converts every line ending in it, which no prose or Markdown gate reports. - -The first prose invocation gates. The second reports a character that no tier covers, and it exits non-zero locally whenever findings exist. It is warn-only in CI because the workflow step sets `continue-on-error: true`, not because the command is lenient, so a non-zero exit locally is the expected result rather than a problem. - -Scope a run to what changed, which matches the correct-as-next-edited rule: - -```sh -python3 scripts/prose_lint.py . --diff origin/develop -``` - -Discovery reads what git tracks plus what it is not ignoring, so a new file is read before it is staged and a `--diff` run counts every line of one as added. Reading the tracked list alone meant a new file was invisible to both modes until it was staged, which reported clean on exactly the file a change existed to add. An ignored path stays out, since a build output is not authored text, and an explicit path is always read whatever git says about it. That holds where git can describe the tree. Where it cannot, the run warns on stderr and falls back to a filesystem walk that skips the generated roots by name and applies no ignore rules. - -Every run states its scope on stderr, as the files read and, for a diff-scoped run, the changed lines inside them. A gate that read nothing otherwise prints what a gate with nothing to report prints, and each false clean found so far exited 0 in silence. - -## Runbooks - -### Audit the fleet - -```sh -python3 spec/audit.py # every cataloged repo -python3 spec/audit.py [RepoName] # one repo -python3 spec/audit.py --issue [RepoName] -``` - -Findings are a point-in-time snapshot read live over the API. Re-run before acting on one, and quote the run stamp in any issue derived from it. The deterministic subset lives here, and the full letter-and-intent verdict is [AUDIT.md](./AUDIT.md). No project-type check in `spec/project-types.json` runs here, and the cross-cutting ones are covered only in part, so read a clean run as evidence for the subset above and not for AUDIT.md section 4. - -### Apply or verify repository configuration - -```sh -repo-config/configure.sh check owner/repo release|operational -repo-config/configure.sh apply owner/repo release|operational -``` - -**Always pass the command, the repository, and the model.** A bare `repo-config/configure.sh` with no arguments defaults to `apply` against the current repo, so an invocation meant to test whether the script runs performs a live write instead. Never run it without a command. The repository argument matters for the same reason now that the fleet runs this copy rather than its own: an omitted target resolves to this repository, and applying the fleet configuration to the hub while meaning to configure a downstream repo is a well-formed write to the wrong place. The model is the third argument for the same reason. This checkout has the registry beside the script, so a repo the registry does not yet name resolves through `defaults.workflowModel` to `release` rather than aborting, and an operational repo then takes the release `develop` ruleset. - -`check` is read-only and exits non-zero on drift. `apply` is idempotent and drives entirely from the committed payloads, so it is a no-op on a conformant repo. - -`apply` is not a narrow toggle. One run patches every key in `repo-config/settings.json`, sets the default branch, enables both Dependabot features, and creates or updates both branch rulesets. On a repository that has deliberately drifted it silently reasserts the fleet configuration. - -The model argument selects which develop payload is applied, so passing the wrong one applies the wrong ruleset. - -## Backup and Recovery - -The repository is the record, and GitHub holds it. Nothing here keeps state outside git. - -A deleted branch is recoverable from any full clone that still has the commit, which is the recovery path when a branch is deleted while another pull request is based on it: - -```sh -git push origin [sha]:refs/heads/[branch] -``` - -Never use `--depth 1` on a clone that will amend or force-push, because a shallow clone severs the merge base and orphans the branch. - -## Logs and Debugging - -Workflow runs are the log. `gh run list --branch [branch]` and `gh run view [id] --log-failed` reach them. - -A local gate reproduces a CI failure exactly, because CI runs the same commands listed under Runbooks against the same committed configuration. Reproduce locally before reading workflow logs. - -## Tool Usage - -The Docker linters pull `:latest` deliberately, so a local run matches whatever CI resolved: - -```sh -docker run --rm --pull=always -v "$PWD":/workdir --workdir /workdir davidanson/markdownlint-cli2:latest "**/*.md" -docker run --rm --pull=always -v "$PWD":/workdir --workdir /workdir ghcr.io/streetsidesoftware/cspell:latest --no-progress README.md HISTORY.md -``` - -Both commands are the canonical invocations from [GOVERNANCE.md](./GOVERNANCE.md). markdownlint reads every Markdown file, while cspell reads `README.md` and `HISTORY.md` only. That narrower spelling scope is deliberate, since gating every Markdown file would mean padding `cspell.json` with technical terms without end, and broad live spell-check is the editor extension's job. Widening it here produces noise that no gate acts on. - -The `editorconfig-checker` action is setup-only. Using it alone silently skips the check, so CI invokes the checker itself rather than relying on the action. - -Two `gh` limitations were carried here as permanent behavior for months. **Both were artifacts of a distribution-packaged `gh` 2.46.0, and both are gone on 2.97.0 installed from the official repository.** The GitHub CLI maintainers name that exact range, `2.45.x` and `2.46.x`, as broken by deprecated GitHub APIs, which is the class both belonged to. Re-tested on this host on 2026-08-09 after the upgrade, rather than assumed from the version number: - -- `gh pr checks --json` returned the rollup as JSON. It carried no `--json` flag on 2.46.0, so a watcher built on it printed nothing and a quiet result read as a passing one. -- `gh pr edit --body-file` applied the change and exited 0. On 2.46.0 it failed with a Projects-classic `projectCards` deprecation error whichever field it was given, `--base`, `--title` and `--body-file` alike, since the failure was in the mutation the command built rather than in the field asked for, and it exited non-zero **without applying the change**, so a stale pull request description survived review rounds. - -The `gh api --method PATCH repos/[owner/repo]/pulls/[number]` form still works and is still correct where a host is stuck on an old `gh`, but it is no longer the required path here. **The lesson worth keeping is not either symptom.** A tool old enough to be broken answers `--version` cleanly and looks healthy, so the defect arrived as two documented workarounds rather than as an upgrade, and it was the *floor* that found it rather than either symptom. [docs/host-setup.md](./docs/host-setup.md) states where `gh` must come from, and `scripts/host_gate.py` fails a host below the floor. - -## Configuration Layout - -- [spec/](./spec/) is the machine-readable ground truth, holding project types, the file and section baseline, and required or forbidden secrets. -- [registry/repos.json](./registry/repos.json) is the fleet registry, naming every project with its types, publish mechanism, and status. -- [repo-config/](./repo-config/) holds the branch rulesets, the fleet settings, and the apply script. The payloads carry to the fleet and the script is reached here. It sits outside `.github/`, which is Actions-owned. -- [catalog/](./catalog/) holds reference snippets the audit compares implementations against. -- [reports/](./reports/) holds per-repo audit output. -- [scripts/](./scripts/) holds the gates that run in CI and locally, and that every fleet repository reaches rather than carries. +# Operations + +How this repository is run. It ships no application code, so its operations are the fleet audit, the gates that mirror CI, and the script that applies repository configuration. Those gates and that script serve the whole fleet from this checkout rather than being carried into each repository, per [GOVERNANCE.md "Hub-Hosted Tooling"](./GOVERNANCE.md#hub-hosted-tooling), so every run below is a run from here against a repository named on the command line. + +## Local Verification + +What verifying a change here requires, including the part CI cannot perform. The gates below do run in CI, so a local run of them buys an earlier failure rather than a different one. The two verifications CI never performs are the fleet audit and the repository-configuration check, because each reads live state in another repository over the API and a pull request runner is given neither the credentials nor a target to read. `python3 spec/audit.py --selftest` is what CI runs of the audit, which exercises the checker against its fixtures and reads no repository at all. So a change to [spec/](./spec/), [registry/](./registry/), [repo-config/](./repo-config/) or [AUDIT.md](./AUDIT.md) is verified by running `python3 spec/audit.py` and `repo-config/configure.sh check` from this checkout before the pull request opens, each from the repository root and each taking the arguments the two runbooks below give it. A green pipeline says nothing about either, and taking it as coverage is the failure this section exists to name. + +### Run the gates the way CI runs them + +CI passes explicit `--check` lists, and a bare `python3 scripts/prose_lint.py [file]` runs `DEFAULT_RULES`, which is those two lists together. What differs is the exit code rather than the coverage. CI gates on nine of the ten and reports `charset-unknown` warn-only, where a bare run exits non-zero on any of the ten. `sentence-split` and `sentence-length` are in neither, so nothing below runs them and a local run reaches them only by naming them. Run the CI invocations: + +```sh +uvx ruff@latest check . +uvx ruff@latest format --check . +uvx mypy@latest +uvx coverage@latest run --source=scripts,spec,host-setup -m unittest discover -s scripts/tests +uvx coverage@latest run --source=scripts,spec,host-setup --append spec/audit.py --selftest +uvx coverage@latest run --source=scripts,spec,host-setup --append host-setup/agent-safety/gh-write-guard.py --selftest +uvx coverage@latest run --source=scripts,spec,host-setup --append host-setup/agent-safety/test_install.py +uvx coverage@latest report +python3 scripts/build_dist.py --check +python3 scripts/repo_gate.py +python3 scripts/prose_lint.py . --check charset --check semicolon --check dash --check dupword --check spelling --check comment-wrap --check comment-case --check home-path --check dead-path +python3 scripts/prose_lint.py . --check charset-unknown --summary +for f in registry/*.json spec/*.json repo-config/*.json; do jq empty "$f"; done +python3 spec/validate.py +docker run --rm --pull=always -v "$PWD":/check --workdir /check mstruebing/editorconfig-checker:latest +scripts=(); while IFS= read -r f; do scripts+=("$f"); done < <(git ls-files '*.sh'); docker run --rm --pull=always -v "$PWD":/mnt --workdir /mnt koalaman/shellcheck:stable "${scripts[@]}" +docker run --rm --pull=always -e PS_SCRIPTS="$(git ls-files '*.ps1')" -v "$PWD":/mnt --workdir /mnt mcr.microsoft.com/powershell:latest pwsh -NoProfile -Command 'Set-PSRepository PSGallery -InstallationPolicy Trusted; Install-Module PSScriptAnalyzer -RequiredVersion 1.23.0 -Force -Scope AllUsers; Import-Module PSScriptAnalyzer; $files = $env:PS_SCRIPTS -split "\s+" | Where-Object { $_ }; if (-not $files) { Write-Host "no PowerShell scripts are tracked"; exit 0 }; $found = @(); foreach ($f in $files) { $found += Invoke-ScriptAnalyzer -Path $f -Settings ./PSScriptAnalyzerSettings.psd1 }; Write-Host "Checked $($files.Count) file(s)"; if ($found) { $found | Format-Table RuleName,Severity,ScriptName,Line,Message -AutoSize | Out-String -Width 200 | Write-Host; exit 1 }; Write-Host "no findings"' +``` + +The `test_install.py` line behaves differently here than in CI, stated so its failure reads as the verdict it is. Its report cases install from this checkout and assert the machine then reads as current. An install from a checkout carrying uncommitted changes records a dirty stamp that reads as stale. So on a working tree mid-change those cases fail by design where CI's clean checkout passes. The remedy is to run them again once the change is committed, not to read the failure as a regression. + +The two container lines that take a file list differ from the workflow in **form** and not in what they check, and both differences exist because this runbook runs on a developer's machine where the workflow runs on `ubuntu-latest`. The shell list is collected with a `while read` loop rather than the workflow's `mapfile`, since `mapfile` arrives in bash 4 and macOS ships 3.2, and it stays an array so a path carrying whitespace is still passed as one argument. The PowerShell list splits on whitespace rather than on a newline, since a shell joins `git ls-files` output with newlines and PowerShell joins it with spaces, and splitting on the newline alone hands the analyzer one argument holding every path, which it reports as one file it cannot find and a clean run over nothing. + +Two gaps in that list are CI's rather than this runbook's, reproduced here so a local run matches CI rather than quietly exceeding it. The `jq` glob covers `repo-config/*.json` and does not reach `repo-config/operational/develop.json`, so a malformed operational payload passes. The second is that `sentence-split` and `sentence-length` are implemented and tested but named by no invocation, so nothing runs them. + +Run the `editorconfig-checker` line before pushing a new file, and before pushing an existing file that a script rewrote rather than an editor. This repository defaults to LF, which most tooling already writes, but a Windows-habituated editor that writes CRLF regardless of file type, or a new `.bat`/`.cmd` file, still fails that check on its first CI run rather than locally. A scripted rewrite is the same hazard on a file that was already correct, since reading and rewriting a whole file in text mode converts every line ending in it, which no prose or Markdown gate reports. + +The first prose invocation gates. The second reports a character that no tier covers, and it exits non-zero locally whenever findings exist. It is warn-only in CI because the workflow step sets `continue-on-error: true`, not because the command is lenient, so a non-zero exit locally is the expected result rather than a problem. + +Scope a run to what changed, which matches the correct-as-next-edited rule: + +```sh +python3 scripts/prose_lint.py . --diff origin/develop +``` + +Discovery reads what git tracks plus what it is not ignoring, so a new file is read before it is staged and a `--diff` run counts every line of one as added. Reading the tracked list alone meant a new file was invisible to both modes until it was staged, which reported clean on exactly the file a change existed to add. An ignored path stays out, since a build output is not authored text, and an explicit path is always read whatever git says about it. That holds where git can describe the tree. Where it cannot, the run warns on stderr and falls back to a filesystem walk that skips the generated roots by name and applies no ignore rules. + +Every run states its scope on stderr, as the files read and, for a diff-scoped run, the changed lines inside them. A gate that read nothing otherwise prints what a gate with nothing to report prints, and each false clean found so far exited 0 in silence. + +## Runbooks + +### Audit the fleet + +```sh +python3 spec/audit.py # every cataloged repo +python3 spec/audit.py [RepoName] # one repo +python3 spec/audit.py --issue [RepoName] +``` + +Findings are a point-in-time snapshot read live over the API. Re-run before acting on one, and quote the run stamp in any issue derived from it. The deterministic subset lives here, and the full letter-and-intent verdict is [AUDIT.md](./AUDIT.md). No project-type check in `spec/project-types.json` runs here, and the cross-cutting ones are covered only in part, so read a clean run as evidence for the subset above and not for AUDIT.md section 4. + +### Apply or verify repository configuration + +```sh +repo-config/configure.sh check owner/repo release|operational +repo-config/configure.sh apply owner/repo release|operational +``` + +**Always pass the command, the repository, and the model.** A bare `repo-config/configure.sh` with no arguments defaults to `apply` against the current repo, so an invocation meant to test whether the script runs performs a live write instead. Never run it without a command. The repository argument matters for the same reason now that the fleet runs this copy rather than its own: an omitted target resolves to this repository, and applying the fleet configuration to the hub while meaning to configure a downstream repo is a well-formed write to the wrong place. The model is the third argument for the same reason. This checkout has the registry beside the script, so a repo the registry does not yet name resolves through `defaults.workflowModel` to `release` rather than aborting, and an operational repo then takes the release `develop` ruleset. + +`check` is read-only and exits non-zero on drift. `apply` is idempotent and drives entirely from the committed payloads, so it is a no-op on a conformant repo. + +`apply` is not a narrow toggle. One run patches every key in `repo-config/settings.json`, sets the default branch, enables both Dependabot features, and creates or updates both branch rulesets. On a repository that has deliberately drifted it silently reasserts the fleet configuration. + +The model argument selects which develop payload is applied, so passing the wrong one applies the wrong ruleset. + +## Backup and Recovery + +The repository is the record, and GitHub holds it. Nothing here keeps state outside git. + +A deleted branch is recoverable from any full clone that still has the commit, which is the recovery path when a branch is deleted while another pull request is based on it: + +```sh +git push origin [sha]:refs/heads/[branch] +``` + +Never use `--depth 1` on a clone that will amend or force-push, because a shallow clone severs the merge base and orphans the branch. + +## Logs and Debugging + +Workflow runs are the log. `gh run list --branch [branch]` and `gh run view [id] --log-failed` reach them. + +A local gate reproduces a CI failure exactly, because CI runs the same commands listed under Runbooks against the same committed configuration. Reproduce locally before reading workflow logs. + +## Tool Usage + +The Docker linters pull `:latest` deliberately, so a local run matches whatever CI resolved: + +```sh +docker run --rm --pull=always -v "$PWD":/workdir --workdir /workdir davidanson/markdownlint-cli2:latest "**/*.md" +docker run --rm --pull=always -v "$PWD":/workdir --workdir /workdir ghcr.io/streetsidesoftware/cspell:latest --no-progress README.md HISTORY.md +``` + +Both commands are the canonical invocations from [GOVERNANCE.md](./GOVERNANCE.md). markdownlint reads every Markdown file, while cspell reads `README.md` and `HISTORY.md` only. That narrower spelling scope is deliberate, since gating every Markdown file would mean padding `cspell.json` with technical terms without end, and broad live spell-check is the editor extension's job. Widening it here produces noise that no gate acts on. + +The `editorconfig-checker` action is setup-only. Using it alone silently skips the check, so CI invokes the checker itself rather than relying on the action. + +Two `gh` limitations were carried here as permanent behavior for months. **Both were artifacts of a distribution-packaged `gh` 2.46.0, and both are gone on 2.97.0 installed from the official repository.** The GitHub CLI maintainers name that exact range, `2.45.x` and `2.46.x`, as broken by deprecated GitHub APIs, which is the class both belonged to. Re-tested on this host on 2026-08-09 after the upgrade, rather than assumed from the version number: + +- `gh pr checks --json` returned the rollup as JSON. It carried no `--json` flag on 2.46.0, so a watcher built on it printed nothing and a quiet result read as a passing one. +- `gh pr edit --body-file` applied the change and exited 0. On 2.46.0 it failed with a Projects-classic `projectCards` deprecation error whichever field it was given, `--base`, `--title` and `--body-file` alike, since the failure was in the mutation the command built rather than in the field asked for, and it exited non-zero **without applying the change**, so a stale pull request description survived review rounds. + +The `gh api --method PATCH repos/[owner/repo]/pulls/[number]` form still works and is still correct where a host is stuck on an old `gh`, but it is no longer the required path here. **The lesson worth keeping is not either symptom.** A tool old enough to be broken answers `--version` cleanly and looks healthy, so the defect arrived as two documented workarounds rather than as an upgrade, and it was the *floor* that found it rather than either symptom. [docs/host-setup.md](./docs/host-setup.md) states where `gh` must come from, and `scripts/host_gate.py` fails a host below the floor. + +## Configuration Layout + +- [spec/](./spec/) is the machine-readable ground truth, holding project types, the file and section baseline, and required or forbidden secrets. +- [registry/repos.json](./registry/repos.json) is the fleet registry, naming every project with its types, publish mechanism, and status. +- [repo-config/](./repo-config/) holds the branch rulesets, the fleet settings, and the apply script. The payloads carry to the fleet and the script is reached here. It sits outside `.github/`, which is Actions-owned. +- [catalog/](./catalog/) holds reference snippets the audit compares implementations against. +- [reports/](./reports/) holds per-repo audit output. +- [scripts/](./scripts/) holds the gates that run in CI and locally, and that every fleet repository reaches rather than carries. diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 index 61acfa36..afe5ec15 100644 --- a/PSScriptAnalyzerSettings.psd1 +++ b/PSScriptAnalyzerSettings.psd1 @@ -1,13 +1,13 @@ -@{ - # Every default rule runs, and two are excluded with their reasons, matching how the shell gate takes shellcheck's defaults and disables a finding inline where the finding is wrong for the program being quoted. - ExcludeRules = @( - # The scripts under host-setup/windows write their report to the console as their whole purpose, mirroring the printf calls in their Linux siblings. - # Write-Output is not merely a different spelling here: these functions return exit codes through the pipeline, so report text on the same stream would arrive at the caller as a return value. - 'PSAvoidUsingWriteHost' - - # Every script here already carries a preview and a consent step, in the shape its Linux sibling uses: -DryRun prints what would run, and a confirm prompt asks before the host changes. - # Supporting ShouldProcess would add -WhatIf and -Confirm beside them, so a reader would face two spellings of preview and two of consent, one of them undocumented. - # A function is named for what it does instead, and the rule is declined here rather than worked around by renaming setters into something they are not. - 'PSUseShouldProcessForStateChangingFunctions' - ) -} +@{ + # Every default rule runs, and two are excluded with their reasons, matching how the shell gate takes shellcheck's defaults and disables a finding inline where the finding is wrong for the program being quoted. + ExcludeRules = @( + # The scripts under host-setup/windows write their report to the console as their whole purpose, mirroring the printf calls in their Linux siblings. + # Write-Output is not merely a different spelling here: these functions return exit codes through the pipeline, so report text on the same stream would arrive at the caller as a return value. + 'PSAvoidUsingWriteHost' + + # Every script here already carries a preview and a consent step, in the shape its Linux sibling uses: -DryRun prints what would run, and a confirm prompt asks before the host changes. + # Supporting ShouldProcess would add -WhatIf and -Confirm beside them, so a reader would face two spellings of preview and two of consent, one of them undocumented. + # A function is named for what it does instead, and the rule is declined here rather than worked around by renaming setters into something they are not. + 'PSUseShouldProcessForStateChangingFunctions' + ) +} diff --git a/ProjectTemplate.code-workspace b/ProjectTemplate.code-workspace index 567a709b..04bebf88 100644 --- a/ProjectTemplate.code-workspace +++ b/ProjectTemplate.code-workspace @@ -1,26 +1,26 @@ -{ - "folders": [ - { "path": "." } - ], - "settings": { - "markdown.extension.toc.levels": "2..3", - "files.trimTrailingWhitespace": true, - "[markdown]": { "files.trimTrailingWhitespace": false }, - "[plaintext]": { "files.trimTrailingWhitespace": false }, - "files.encoding": "utf8", - "git.alwaysSignOff": true - }, - "extensions": { - "recommendations": [ - "anthropic.claude-code", - "arahata.linter-actionlint", - "davidanson.vscode-markdownlint", - "editorconfig.editorconfig", - "fanaticpythoner.better-todo-tree", - "github.vscode-github-actions", - "streetsidesoftware.code-spell-checker", - "timonwong.shellcheck", - "yzhang.markdown-all-in-one" - ] - } -} +{ + "folders": [ + { "path": "." } + ], + "settings": { + "markdown.extension.toc.levels": "2..3", + "files.trimTrailingWhitespace": true, + "[markdown]": { "files.trimTrailingWhitespace": false }, + "[plaintext]": { "files.trimTrailingWhitespace": false }, + "files.encoding": "utf8", + "git.alwaysSignOff": true + }, + "extensions": { + "recommendations": [ + "anthropic.claude-code", + "arahata.linter-actionlint", + "davidanson.vscode-markdownlint", + "editorconfig.editorconfig", + "fanaticpythoner.better-todo-tree", + "github.vscode-github-actions", + "streetsidesoftware.code-spell-checker", + "timonwong.shellcheck", + "yzhang.markdown-all-in-one" + ] + } +} diff --git a/README.md b/README.md index ae727ee0..8655a98a 100644 --- a/README.md +++ b/README.md @@ -1,387 +1,387 @@ -# ProjectTemplate <!-- omit from toc --> - -Agent enablement for a fleet of repositories: autonomy and repeatable quality inside guardrails. - -## Build and Distribution <!-- omit from toc --> - -- **Source Code**: [GitHub][github-link] for source, issues, discussions, and CI/CD pipelines. -- **Versioned Releases**: [GitHub Releases][releases-link] for version-tagged source archives. - -### Build Status <!-- omit from toc --> - -[![Releases Build][releases-build-shield]][actions-link]\ -[![Last Commit][last-commit-shield]][commits-link] - -### Releases <!-- omit from toc --> - -[![GitHub Release][github-release-shield]][releases-link]\ -[![GitHub Pre-Release][github-pre-release-shield]][releases-link] - -### Release Notes <!-- omit from toc --> - -**Version**: 2.0 - -**Summary**: - -- Agent enablement for the fleet: shared rules, a machine-readable spec, a fleet registry, per-repo audit reports, and an audit-agent instruction set, so an agent works autonomously inside guardrails that prove the result. Ships no application code. - -See [Release History][history] for the full history. - -## Getting Started <!-- omit from toc --> - -This repo has two kinds of reader, a human and an AI coding agent, and they enter at different doors. **An agent starts at [AGENTS.md][agents]**, which maps a task to the document that governs it and is written to be read one section at a time. **A human starts here**, and the table below is the fork: find the row that describes why you opened this page, and read the one it points at rather than the whole file. - -| You are | Your question | Start at | -| --- | --- | --- | -| Browsing GitHub | What is this, and why does it exist? | [What This Repo Is][what-this-repo-is] and [What It Achieves][what-it-achieves] | -| Setting up a machine where an agent runs | How do I deploy the host guardrails? | [`docs/host-setup.md`][host-setup] | -| Adopting the rules in a repository | How do I stand a repo up? | [`STANDUP.md`][standup], then [`AUDIT.md`][audit] | -| Keeping a repository in line | It is stood up already. How do I bring it up to the current rules? | [`RESYNC.md`][resync] | -| Blocked by a rule | How do I diverge from one, or grant a write the guard denies? | [Diverging From a Rule][diverging-from-a-rule] | -| Reporting a defect or proposing a rule | Where does that go, and what does it need? | [Questions or Issues][questions-or-issues] | -| An AI coding agent | Which document governs the task in front of me? | [`AGENTS.md`][agents] | - -Nothing here is installed as a dependency. The rules are read, the baseline is carried into a repository as files it then owns, and the one thing that is genuinely installed is the host guardrail kit, which lands in your home directory rather than in any repository. - -## Table of Contents <!-- omit from toc --> - -- [What This Repo Is](#what-this-repo-is) -- [What It Achieves](#what-it-achieves) -- [How This Repo Operates](#how-this-repo-operates) -- [Using This Repo](#using-this-repo) - - [Deploy the Host Guardrails](#deploy-the-host-guardrails) - - [Install the Fleet Skills](#install-the-fleet-skills) - - [Carry the Rules Into a Repository](#carry-the-rules-into-a-repository) - - [Adopting Outside This Fleet](#adopting-outside-this-fleet) -- [Diverging From a Rule](#diverging-from-a-rule) - - [A Repository Diverging From a Carried Unit](#a-repository-diverging-from-a-carried-unit) - - [A Write the Host Guard Denies](#a-write-the-host-guard-denies) -- [Rules](#rules) - - [Always](#always) - - [Never](#never) - - [If a C# Project](#if-a-c-project) - - [If a Python Project](#if-a-python-project) - - [If Both C# and Python](#if-both-c-and-python) - - [If Publishing a Package (NuGet or PyPI)](#if-publishing-a-package-nuget-or-pypi) - - [If a Docker Image](#if-a-docker-image) - - [For a README or Human-Facing Doc](#for-a-readme-or-human-facing-doc) - - [For Workflows](#for-workflows) -- [Questions or Issues](#questions-or-issues) -- [Development Environment Setup](#development-environment-setup) -- [3rd Party Tools](#3rd-party-tools) -- [License](#license) - -## What This Repo Is - -**The purpose is agent enablement.** An AI coding agent is fast and inconsistent, so a fleet built by one drifts a different way in every repository, and the drift stays invisible until something breaks where it matters. What this repo makes repeatable is the outcome: an agent stands a repository up, changes it, and releases it on its own, and lands in the same known-good shape every time. The guardrails are what make granting that autonomy sound rather than reckless. - -**Guardrails here enable rather than restrain.** A rule earns its place by removing a decision an agent would otherwise make differently every time, or by making a failure loud that would otherwise pass green. Write safety bounds what an agent can reach outside the project in front of it, the review loop closes before anything merges, and the audit proves the result instead of accepting the agent's report of it. Autonomy extends exactly as far as the verification reaches. - -**Nothing here is finished.** Every rule traces to a specific failure, nearly all of them observed in this fleet rather than imagined, and a procedure that lets a new one through is corrected as part of the work that found it. The ground truth improves by being used. - -This repo is the single home for those rules, a machine-readable spec they are checked against, a registry of the projects, and an audit-agent instruction set. It ships no application code. Each project owns its own implementation and is **audited** against the ground truth here, to the letter (exact file, section, or config) or to intent (an equivalent outcome). - -- **[AGENTS.md][agents]** - the agent entry point: context and delegation rules, plus the map from a task to the section that governs it. -- **[GOVERNANCE.md][governance]** - cross-cutting rules for AI coding agents: git, branching, release model, doc style, the recurring-violation rules (comments, ASCII charset, US spelling, line endings), PR review etiquette, and workflow YAML conventions. -- **[CODESTYLE.md][codestyle]** - code style for .NET and Python. -- **[WORKFLOW.md][workflow]** - the CI/CD workflow contract (behavioral guarantees D1-D9) and its audit methodology. -- **[STANDUP.md][standup]** - how an agent stands a repository up and carries the baseline it is owed. -- **[AUDIT.md][audit]** - how an agent audits a repository against the spec and reports drift. -- **[RESYNC.md][resync]** - how an agent brings an already-stood-up repository back into line, in the order the remedies require. -- **[spec/][spec]** - the machine-readable ground truth: project-type requirements, the file/section baseline, required/forbidden secrets, the host tool contract and its version floors, and the preferred README structure. -- **[registry/repos.json][repos]** - the fleet registry: every project, its type(s), publish mechanism, and status (cataloged or standardization backlog). -- **[repo-config/][repo-config]** - branch rulesets and the apply script (kept out of `.github/`, which is Actions-owned), plus the GitHub setup reference. -- **[host-setup/][host-setup-dir]** - the host guardrail kit, which is per machine rather than per repository. -- **[catalog/][catalog]** - reusable reference snippets (workflow tasks, config exemplars, devcontainers) the audit compares implementations against. -- **[reports/][reports]** - per-repo audit output. -- **[docs/][docs]** - the human setup and reference guides: host prerequisites, SSH signing, devcontainers, and the carry procedures. - -**The words a request is phrased in are defined here.** A repository is asked to audit itself against the hub, or to close the review loop on a pull request, and the phrasing carries the whole instruction, so each term below names the file that answers it and a request using one is a complete instruction rather than a starting point for interpretation. - -- **The hub** - this repository. It is the single authority on what a fleet project is supposed to hold, and every rule naming the hub means this one. -- **The fleet** - the projects listed in [registry/repos.json][repos], each audited downward against the ground truth here rather than negotiating its own. -- **Stand a repository up** - carry the baseline a project is owed for its declared types and workflow model, per [STANDUP.md][standup]. An absent file is a baseline that never arrived rather than drift. -- **Audit a repository** - read a live project against the spec and report its drift, per [AUDIT.md][audit]. The audit never edits what it measures, so a fix is a separate change. -- **Resync a repository** - bring an already-stood-up project up to the current hub, per [RESYNC.md][resync]. It audits for the findings and then applies them in order, which includes deleting what the hub hosts rather than carries. -- **Close the review loop** - request a review on every push, confirm it covered the head commit, triage every finding, reply and resolve, and escalate when stuck, per [GOVERNANCE.md "PR Review Etiquette"][governance-pr-review-etiquette]. -- **Carried against reached** - a project carries the content it is audited against and reaches the machinery that is identical everywhere, per [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling]. - -## What It Achieves - -Keeping a fleet of repositories consistent has always been a tax paid in review attention, and it stops scaling at the point where one person can no longer hold every repo in their head. An agent changes that arithmetic in both directions at once. It can apply a convention across every repository in an afternoon, and it can spread a mistake exactly as fast. What makes the speed worth having is a ground truth an agent can read, a gate that proves the result rather than reporting it, and a boundary naming the decisions that are never the agent's to make. Each objective below is a standing capability, with the machinery that delivers it named so the claim is checkable. - -- **Workflow consistency, by contract rather than by copy.** Every repo satisfies one behavioral CI/CD contract ([WORKFLOW.md][workflow], guarantees D1 to D9) instead of inheriting one YAML file it then edits. The fixed part is the orchestration seam, meaning job names, the ruleset-bound required check, and the artifact handoff. What a repo builds inside that seam is its own, so a Hugo site and a NuGet package satisfy the same contract without pretending to be the same pipeline. -- **Technical consistency that does not depend on anyone remembering it.** One line-ending policy, one comment shape, one character set, one US-English convention, and one config per linter shared by the editor, the CLI, and CI. A rule that holds in review therefore holds on a laptop and in the pipeline, because all three read the same file rather than three copies that drift apart. -- **Best practices promoted once, not re-litigated per repo.** A practice that proves itself becomes fleet law in [GOVERNANCE.md][governance] or [CODESTYLE.md][codestyle] and is carried, rather than being rediscovered and re-argued in the next repository. Every rule here traces to a specific failure that actually happened, which is why the collection is opinionated and small rather than exhaustive. -- **Feedback loops that close on the procedure, not the instance.** [AUDIT.md][audit] reads a live repo and reports drift, the per-repo reports in [reports/][reports] record it, and a repo that cannot be stood up from the docs alone is a documentation defect tracked in the [conformance matrix][matrix]. When a downstream agent hits something the procedure did not cover, the fix lands in the procedure so the next repo never meets it. -- **Onboarding a new language or deployment target is a spec change.** Thirteen project types are declared today in [spec/project-types.json][project-types]. Adding one means declaring its detection, its checks, and the files it carries, then proving a context-free agent can stand it up cold. No fleet-wide rewrite, and no per-repo improvisation. -- **Re-deployment that is measured and traceable.** Versions come from git history through NBGV rather than a hand-edited number, a release is always a deliberate act and never a side effect of a merge, and staleness is detected by **content hash against the hub's own past revisions**, so the audit can say whether a repo is behind the canonical or has forked it. A version stamp is a claim a repo can keep while editing the content underneath, so it is never trusted for that answer. -- **Every carried unit declares how much freedom it grants.** This is the distinction that makes the whole thing tolerable to work in, and it is a field on each [spec/files.json][files] entry rather than something a reader infers from the file's shape. An entry that names no level takes `presence`, the most permissive one, so silence grants freedom rather than withholding it: - - | Level | The obligation | Who owns the content | - | --- | --- | --- | - | `verbatim` | Byte-identical to canonical, after governed normalization | The hub. A paraphrase is a defect, not an adaptation. | - | `interface` | Honor a named contract, checked by name and wiring | The repo owns the body entirely. | - | `intent` | Reach the same outcome, judged by meaning | The repo owns the wording and shape. | - | `presence` | The unit exists | The repo owns all of it. | - -- **The human contributes where domain expertise is decisive, and only there.** The maintainer keeps what an agent cannot know or must not decide: creating a repository, granting a write outside the owner boundary, changing a ruleset, approving every merge, and every judgment about the domain a repo actually serves. The agent takes the mechanical scale-out, which is the part that does not benefit from human attention and degrades under it. A repo's own knowledge also has a declared destination rather than an improvised one, chosen by what the content is: `CODESTYLE.md` for conventions beyond the carried rules, `ARCHITECTURE.md` for how a code repo is built, `OPERATIONS.md` for how a live-service repo is run, and `TODO.md` for its backlog. Which of those a repo carries follows from what it is, so this hub holds the two that apply to it. Domain expertise therefore lands somewhere declared instead of being diluted into a carried file that the next re-vendor overwrites. - -## How This Repo Operates - -ProjectTemplate follows the same model it documents, and audits its own rules against itself (it classifies as the source-only project type in [WORKFLOW.md][workflow]). - -The doors a session enters through, and where each leads, matching the "Getting Started" table above: - -```mermaid -flowchart TD - start["new session: human via this README, or agent via AGENTS.md Fleet Bootstrap"] - start -->|"set up a machine"| hostsetup["host-setup/: guardrails, then fleet skills"] - start -->|"repo does not exist yet"| standup["STANDUP.md"] - start -->|"repo exists, in or out of conformance"| resync["RESYNC.md"] - hostsetup --> standup - standup --> audit["AUDIT.md: verify"] - resync --> audit - audit -->|"blocked by a rule"| diverging["Diverging From a Rule"] - audit -->|"gap in the rules or docs"| issues["Questions or Issues"] -``` - -The full entry-point map behind these doors, with the gap register and the roadmap that closes it, is [docs/fleet-map.md][fleet-map]. - -Within this repo, day-to-day development follows the same branching, CI, review, and release model it documents for the fleet: - -- **Branching.** Persistent `main` and `develop`, each with its own ruleset. This repo uses the default `release` workflow model: commit on feature branches only, feature branch to `develop` is squash-merged, `develop` to `main` is a merge commit, and `develop` is forward-only (no `main -> develop` back-merges). Live-service config repos instead use the `operational` model (registry `workflowModel`), with direct signed commits to `develop`, promoted to `main` by an occasional PR. See [GOVERNANCE.md "Branching Model"][governance-branching-model]. -- **CI is lint-only.** There is no build or unit test. The PR gate runs markdownlint, cspell, JSON validation (`jq` parses `registry/`, `spec/`, and `repo-config/`, plus the `spec/validate.py` cross-reference and shape checks), and actionlint, and exposes the ruleset-bound `Check pull request workflow status job` aggregator. The same lint configs (`.markdownlint-cli2.jsonc`, `cspell.json`) drive the editor extensions, the CLI, and CI. -- **Review loop.** Every PR is reviewed by GitHub Copilot, and the agent drives the review loop to green and merges only with explicit maintainer permission. See [GOVERNANCE.md "PR Review Etiquette"][governance-pr-review-etiquette]. -- **Release.** A `develop -> main` merge is promoted through a GitHub release (tag plus a source zip, README, and LICENSE). Versioning is NBGV-driven from [version.json][version]. See [WORKFLOW.md][workflow]. - -## Using This Repo - -Four things are deployed from here, and they land in different places. The host guardrails install once per machine, the fleet skills install once per user on that machine, the baseline is carried once per repository, and the audit is run whenever a repository changes materially. Do them in that order on a new machine, because the guardrails bound every session that follows and retrofitting them means the sessions in between ran unguarded. - -### Deploy the Host Guardrails - -The guardrails are the one component that is installed rather than read, and they are **host state rather than repository content**, because they have to cover ad-hoc sessions in no project at all. They deny a mis-targeted GitHub write under your identity, and a git operation that would only land by bypassing a branch rule. - -```shell -host-setup/agent-safety/install.sh # Linux, WSL, macOS -``` - -```powershell -.\host-setup\agent-safety\install.ps1 # Windows, and the .\ prefix is required -``` - -Restart Claude Code sessions on the machine afterward so the hook and the `CLAUDE.md` blocks load. The installer is idempotent, so re-running it is also how a machine picks up an upstream change to the guard. What it installs, how to verify it, and what it deliberately does not catch are in [`host-setup/agent-safety/README.md`][agent-safety], and the surrounding host prerequisites (git identity, SSH signing, `gh`, `docker`, `uv`) are in [`docs/host-setup.md`][host-setup]. - -### Install the Fleet Skills - -The skills are the per-topic rules packaged so they surface in an agent session by trigger, instead of being re-read from the law docs each time. They install once per user per machine, from a hub checkout, and land beside the guardrails rather than in any repository: - -```shell -python3 scripts/skills_install.py # or the scripts/skills_install.sh / .ps1 wrapper -python3 scripts/skills_install.py --report # read-only: is this machine current? -``` - -A host stood up end to end by the [`host-setup/`][host-setup-dir] bootstrap gets this step at the end of its host mode, so a fresh machine finishes with the tools, the identity, and the skills together. [`docs/host-setup.md`][host-setup] "Fleet Skills Install" carries the details, including how the install degrades where the `claude` CLI is absent. - -### Carry the Rules Into a Repository - -A repository that does not exist yet is stood up with [`STANDUP.md`][standup], which is ordered rather than a menu: verify commit identity and signing before the first commit, hand the maintainer what only they can supply, classify the repo and write its [registry][repos] entry, carry the instruction set before authoring anything of your own, then carry the remaining baseline, the workflows, and the settings. The two steps with a closing window are first, because signing has to be live before the first commit and the rules have to be loaded before the first authored file. - -A repository that already exists is measured with [`AUDIT.md`][audit] instead. The audit is read-only and ends in a report under [reports/][reports], so nothing is changed by measuring, and applying what it found is a separate reviewable change per its section 10. Onboarding is complete when the repo passes, or carries a committed report plus a tracking issue for the residual deltas, per [GOVERNANCE.md "Repository Onboarding and Conformance"][governance-repository-onboarding-and-conformance]. - -A repository that is stood up already and has fallen behind is resynced with [`RESYNC.md`][resync], which is the third entry point and the one a request to sync a repository with the hub resolves to. It runs the audit for the findings and then applies them in an order that matters: the rules first, because they govern what comes after them, then the deletions, because a re-vendor would otherwise refresh a file that is about to go, then the re-vendors, the workflow contracts, and the repository configuration. It also states what the measurement cannot see, since a carried file at `intent` fidelity is checked for presence alone and a hub revision inside one raises no finding at all. - -The mechanical helpers that go with those procedures are documented beside them: [`docs/repo-config-carry.md`][repo-config-carry] for branch rulesets and repository settings, and [`docs/content-import.md`][content-import] for importing existing content into a new repo. - -### Adopting Outside This Fleet - -The rules, the spec, and the procedures are readable and reusable by anyone, and the guardrail kit installs on any host. What does not transfer is the registry, since [registry/repos.json][repos] lists this fleet's projects, and standing a repository up writes an entry in it. Adopting outside this fleet therefore means running your own hub, forked or copied, holding your own registry and your own reports, with `AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, `WORKFLOW.md`, and `spec/` adapted to what your projects actually are. There is no supported mode where an outside repository points at this hub's registry. - -## Diverging From a Rule - -A rule that cannot be diverged from is a rule people work around silently, which is worse than the divergence, so both kinds of exception have a declared channel. The two kinds are genuinely different: one is a repository not matching a carried unit, and the other is a host guard denying a write. Neither is granted by editing the thing that blocked you. - -### A Repository Diverging From a Carried Unit - -**Check the unit's fidelity level first, because most apparent divergences are not divergences at all.** Each [spec/files.json][files] entry declares one of `verbatim`, `interface`, `intent`, or `presence`, defaulting to `presence`, and the two permissive levels already hand the repo its content outright. A repo rewriting a `presence` or `intent` file to suit itself is exercising the freedom the level grants rather than breaking a rule, and the audit asserts nothing beyond presence for it. See [spec/fidelity-model.md][fidelity-model] for which unit sits where and why. - -**A divergence from a `verbatim` or `interface` unit is recorded, not hidden.** The ledger is [spec/divergences.json][divergences], where each entry names the path, the repos, a disposition, and a reason, and `spec/fidelity_honesty.py --report` joins it against live fleet reality to regenerate [reports/divergences.md][divergences-report]. A divergence recorded as `accepted` is a legitimate permanent one and no further action is owed. A live divergence with no entry renders as `UNTRIAGED`, which is the point: the audit does not care whether a difference is deliberate, only whether someone decided about it. Edit the ledger and regenerate the report, never the report. - -**A rule that is wrong for many repositories is a hub defect rather than a per-repo exception.** [AUDIT.md][audit] section 9 says so directly, that a repeated letter miss many repos share is a signal the spec needs adjusting, so raise it here instead of accumulating one exception per repository. - -### A Write the Host Guard Denies - -The guard denies a `gh` write whose explicit target sits under an owner other than the checkout's `origin` owner, which is the shape that once put a stray comment on a stranger's repository. Sibling repositories under the same owner are allowed, so the denial appears only on a write that leaves the owner, and the common case that raises it is a fork, where `origin` is yours and `upstream` is the project you forked from. - -The only way past it is a grant the maintainer makes **outside the session**, in `GH_WRITE_GUARD_ALLOW`. It is deliberately not something an agent can do for itself once blocked, so an inline `GH_WRITE_GUARD_ALLOW=owner/repo gh ...` prefix and an `export` inside a shell call both leave the write denied. The worked example, the file the grant goes in, and how to confirm one took effect are in [`docs/host-setup.md` "Granting a Write the Guard Denies"][host-setup-granting-a-write-the-guard-denies]. - -## Rules - -A human-readable index of the rules agents enforce, implement, and audit. The authority for each is [GOVERNANCE.md][governance], [CODESTYLE.md][codestyle], and [WORKFLOW.md][workflow]. The machine-checkable form lives in [spec/][spec]. - -### Always - -- Sign every commit (SSH or GPG). -- Branch feature -> develop (squash) -> main (merge commit), and develop is forward-only. -- Drive every PR through the Copilot review loop and merge only with maintainer approval. -- Write US English and ASCII only (no em-dash, straight quotes). -- Write docs and comments in the present tense, describing only the current state, never as a change from a prior one. -- Keep comments concise and only for the non-obvious, and never grow them on edit. -- Follow `.editorconfig` line endings (CRLF default, LF for shell and Docker) and preserve a file's endings on edit. -- One logical paragraph per line, with a trailing `\` for an intentional hard break. -- Pin every GitHub Action to a commit SHA with a version comment. -- Share one lint config per tool across the editor, the CLI, and CI. -- Run the repo's whole lint gate before pushing, not just the parts that look relevant. -- Make gates fail loud, since a gate that stops gating must error or annotate, never pass silently. -- Favor VS Code tasks and launch configs for building, running, and testing over ad-hoc shell scripts. - -### Never - -- Never force-push or rewrite shared history. -- Never treat a merge as a release. Publishing is a separate, explicit step. -- Never blanket-delete a workflow run's artifacts. -- Never store a static key when OIDC Trusted Publishing is available. - -### If a C# Project - -- Carry the shared `[*.cs]` block in `.editorconfig` and build with zero warnings. - -### If a Python Project - -- Configure ruff and a type checker in `pyproject.toml`, either pyright strict or mypy in CI with pyright editor-only. Whichever runs in CI is the gate. - -### If Both C# and Python - -- Both sections above apply, and a repo can be both (a C# app plus a Python subtree). The Python is either a full uv project (`uv.lock`, `uv run`) or a stdlib-only `uvx` scripts subtree (no `uv.lock`, `pyproject.toml` carries lint/type config only). See [CODESTYLE.md][codestyle] "Two profiles". - -### If Publishing a Package (NuGet or PyPI) - -- Publish via OIDC Trusted Publishing, never a stored API key. - -### If a Docker Image - -- Cache layers to a registry tag (never `type=gha`) and publish the size-limited Docker Hub README separately. - -### For a README or Human-Facing Doc - -- Follow the section order in [spec/readme-structure.md][readme-structure] and put every URI as a grouped, alphabetized reference link at the bottom. - -### For Workflows - -- Make GitHub Actions satisfy the [WORKFLOW.md][workflow] contract (guarantees D1-D9), which the audit verifies. - -## Questions or Issues - -File everything at [Issues][issues-link], including a defect in a rule, a procedure that did not survive contact with a real repository, and a proposal for a new rule. Use [Discussions][discussions-link] for an open question that is not yet a defect. This repo ships no application code, so an issue here is about the rules, the spec, the procedures, or the tooling under [`scripts/`][scripts] and [`spec/`][spec], never about a downstream project's behavior, which belongs on that project's own tracker. - -An issue is most useful when it names the ground truth it disagrees with, so include the file and section that states the rule, the repository and branch where the problem was observed, and what you expected instead. A finding measured against a specific commit is worth more than one measured against a memory of the docs, per [GOVERNANCE.md "Verification Discipline"][governance-verification-discipline]. - -Two kinds of report are worth calling out because they are the ones that improve the procedures rather than one repository: - -- **A procedure that could not be followed cold.** The onboarding docs are sufficient only when a context-free agent can stand a repo shape up from them alone, so a step that needed knowledge the docs never gave is a documentation defect tracked in the [conformance matrix][matrix] and fixed here rather than worked around per repo. -- **A rule that many repositories miss the same way.** That is a signal the spec needs adjusting rather than a queue of per-repo exceptions, per [AUDIT.md][audit] section 9. - -Issues are also filed here **by agents working in downstream repositories**, which is the normal path rather than an exception, since the agent that hit the gap is the one holding the evidence for it. - -## Development Environment Setup - -Contributors sign every commit. See [docs/ssh-signing.md][ssh-signing] for SSH commit-signing setup, [docs/host-setup.md][host-setup] for host prerequisites, and [docs/devcontainer.md][devcontainer] for devcontainer SSH-agent forwarding. Run the linters before pushing (see [GOVERNANCE.md "Running the Linters Locally"][governance-running-the-linters-locally-known-working-invocations]). - -Changes land the same way every fleet change does: a feature branch, a squash merge into `develop`, a Copilot review loop driven to green, and a merge only with the maintainer's explicit approval. The backlog is [`TODO.md`][todo], which holds the work that is ready to pick up along with the reasoning behind each item, so read it before proposing something it already covers. - -## 3rd Party Tools - -The third-party tools, libraries, and actions this project depends on. - -| Tool | Role | -| --- | --- | -| [cspell][cspell-link] | Spell checker. | -| [editorconfig-checker][editorconfig-checker-link] | Line-ending and whitespace linter. | -| [GitHub Actions][github-actions-link] | CI and automation runner. | -| [GitHub Dependabot][dependabot-link] | Dependency update bot. | -| [Markdown All in One][markdown-all-in-one-link] | Markdown editing extension. | -| [markdownlint-cli2][markdownlint-link] | Markdown linter. | -| [Nerdbank.GitVersioning][nbgv-link] | Version computation from git height. | - -## License - -Licensed under the [MIT License][license]\ -![License][license-shield] - -<!-- Sections --> - -[diverging-from-a-rule]: #diverging-from-a-rule -[questions-or-issues]: #questions-or-issues -[what-it-achieves]: #what-it-achieves -[what-this-repo-is]: #what-this-repo-is - -<!-- Shields --> - -[github-pre-release-shield]: https://img.shields.io/github/v/release/ptr727/ProjectTemplate?include_prereleases&label=GitHub%20Pre-Release&logo=github -[github-release-shield]: https://img.shields.io/github/v/release/ptr727/ProjectTemplate?logo=github&label=GitHub%20Release -[last-commit-shield]: https://img.shields.io/github/last-commit/ptr727/ProjectTemplate?logo=github&label=Last%20Commit -[license-shield]: https://img.shields.io/github/license/ptr727/ProjectTemplate?label=License -[releases-build-shield]: https://img.shields.io/github/actions/workflow/status/ptr727/ProjectTemplate/publish-release.yml?event=schedule&logo=github&label=Releases%20Build - -<!-- Distribution --> - -[actions-link]: https://github.com/ptr727/ProjectTemplate/actions -[commits-link]: https://github.com/ptr727/ProjectTemplate/commits -[discussions-link]: https://github.com/ptr727/ProjectTemplate/discussions -[github-link]: https://github.com/ptr727/ProjectTemplate -[issues-link]: https://github.com/ptr727/ProjectTemplate/issues -[releases-link]: https://github.com/ptr727/ProjectTemplate/releases - -<!-- Repo --> - -[agent-safety]: ./host-setup/agent-safety/README.md -[agents]: ./AGENTS.md -[audit]: ./AUDIT.md -[catalog]: ./catalog/ -[codestyle]: ./CODESTYLE.md -[content-import]: ./docs/content-import.md -[devcontainer]: ./docs/devcontainer.md -[divergences]: ./spec/divergences.json -[divergences-report]: ./reports/divergences.md -[docs]: ./docs/ -[fidelity-model]: ./spec/fidelity-model.md -[files]: ./spec/files.json -[fleet-map]: ./docs/fleet-map.md -[governance]: ./GOVERNANCE.md -[governance-branching-model]: ./GOVERNANCE.md#branching-model -[governance-hub-hosted-tooling]: ./GOVERNANCE.md#hub-hosted-tooling -[governance-pr-review-etiquette]: ./GOVERNANCE.md#pr-review-etiquette -[governance-repository-onboarding-and-conformance]: ./GOVERNANCE.md#repository-onboarding-and-conformance -[governance-running-the-linters-locally-known-working-invocations]: ./GOVERNANCE.md#running-the-linters-locally-known-working-invocations -[governance-verification-discipline]: ./GOVERNANCE.md#verification-discipline -[history]: ./HISTORY.md -[host-setup]: ./docs/host-setup.md -[host-setup-dir]: ./host-setup/ -[host-setup-granting-a-write-the-guard-denies]: ./docs/host-setup.md#granting-a-write-the-guard-denies -[license]: ./LICENSE -[matrix]: ./reports/conformance-matrix.md -[project-types]: ./spec/project-types.json -[readme-structure]: ./spec/readme-structure.md -[repo-config]: ./repo-config/ -[repo-config-carry]: ./docs/repo-config-carry.md -[reports]: ./reports/ -[repos]: ./registry/repos.json -[resync]: ./RESYNC.md -[scripts]: ./scripts/ -[spec]: ./spec/ -[ssh-signing]: ./docs/ssh-signing.md -[standup]: ./STANDUP.md -[todo]: ./TODO.md -[version]: ./version.json -[workflow]: ./WORKFLOW.md - -<!-- External --> - -[cspell-link]: https://cspell.org -[dependabot-link]: https://github.com/dependabot -[editorconfig-checker-link]: https://github.com/editorconfig-checker/editorconfig-checker -[github-actions-link]: https://github.com/actions -[markdown-all-in-one-link]: https://marketplace.visualstudio.com/items?itemName=yzhang.markdown-all-in-one -[markdownlint-link]: https://github.com/DavidAnson/markdownlint-cli2 -[nbgv-link]: https://github.com/dotnet/Nerdbank.GitVersioning +# ProjectTemplate <!-- omit from toc --> + +Agent enablement for a fleet of repositories: autonomy and repeatable quality inside guardrails. + +## Build and Distribution <!-- omit from toc --> + +- **Source Code**: [GitHub][github-link] for source, issues, discussions, and CI/CD pipelines. +- **Versioned Releases**: [GitHub Releases][releases-link] for version-tagged source archives. + +### Build Status <!-- omit from toc --> + +[![Releases Build][releases-build-shield]][actions-link]\ +[![Last Commit][last-commit-shield]][commits-link] + +### Releases <!-- omit from toc --> + +[![GitHub Release][github-release-shield]][releases-link]\ +[![GitHub Pre-Release][github-pre-release-shield]][releases-link] + +### Release Notes <!-- omit from toc --> + +**Version**: 2.0 + +**Summary**: + +- Agent enablement for the fleet: shared rules, a machine-readable spec, a fleet registry, per-repo audit reports, and an audit-agent instruction set, so an agent works autonomously inside guardrails that prove the result. Ships no application code. + +See [Release History][history] for the full history. + +## Getting Started <!-- omit from toc --> + +This repo has two kinds of reader, a human and an AI coding agent, and they enter at different doors. **An agent starts at [AGENTS.md][agents]**, which maps a task to the document that governs it and is written to be read one section at a time. **A human starts here**, and the table below is the fork: find the row that describes why you opened this page, and read the one it points at rather than the whole file. + +| You are | Your question | Start at | +| --- | --- | --- | +| Browsing GitHub | What is this, and why does it exist? | [What This Repo Is][what-this-repo-is] and [What It Achieves][what-it-achieves] | +| Setting up a machine where an agent runs | How do I deploy the host guardrails? | [`docs/host-setup.md`][host-setup] | +| Adopting the rules in a repository | How do I stand a repo up? | [`STANDUP.md`][standup], then [`AUDIT.md`][audit] | +| Keeping a repository in line | It is stood up already. How do I bring it up to the current rules? | [`RESYNC.md`][resync] | +| Blocked by a rule | How do I diverge from one, or grant a write the guard denies? | [Diverging From a Rule][diverging-from-a-rule] | +| Reporting a defect or proposing a rule | Where does that go, and what does it need? | [Questions or Issues][questions-or-issues] | +| An AI coding agent | Which document governs the task in front of me? | [`AGENTS.md`][agents] | + +Nothing here is installed as a dependency. The rules are read, the baseline is carried into a repository as files it then owns, and the one thing that is genuinely installed is the host guardrail kit, which lands in your home directory rather than in any repository. + +## Table of Contents <!-- omit from toc --> + +- [What This Repo Is](#what-this-repo-is) +- [What It Achieves](#what-it-achieves) +- [How This Repo Operates](#how-this-repo-operates) +- [Using This Repo](#using-this-repo) + - [Deploy the Host Guardrails](#deploy-the-host-guardrails) + - [Install the Fleet Skills](#install-the-fleet-skills) + - [Carry the Rules Into a Repository](#carry-the-rules-into-a-repository) + - [Adopting Outside This Fleet](#adopting-outside-this-fleet) +- [Diverging From a Rule](#diverging-from-a-rule) + - [A Repository Diverging From a Carried Unit](#a-repository-diverging-from-a-carried-unit) + - [A Write the Host Guard Denies](#a-write-the-host-guard-denies) +- [Rules](#rules) + - [Always](#always) + - [Never](#never) + - [If a C# Project](#if-a-c-project) + - [If a Python Project](#if-a-python-project) + - [If Both C# and Python](#if-both-c-and-python) + - [If Publishing a Package (NuGet or PyPI)](#if-publishing-a-package-nuget-or-pypi) + - [If a Docker Image](#if-a-docker-image) + - [For a README or Human-Facing Doc](#for-a-readme-or-human-facing-doc) + - [For Workflows](#for-workflows) +- [Questions or Issues](#questions-or-issues) +- [Development Environment Setup](#development-environment-setup) +- [3rd Party Tools](#3rd-party-tools) +- [License](#license) + +## What This Repo Is + +**The purpose is agent enablement.** An AI coding agent is fast and inconsistent, so a fleet built by one drifts a different way in every repository, and the drift stays invisible until something breaks where it matters. What this repo makes repeatable is the outcome: an agent stands a repository up, changes it, and releases it on its own, and lands in the same known-good shape every time. The guardrails are what make granting that autonomy sound rather than reckless. + +**Guardrails here enable rather than restrain.** A rule earns its place by removing a decision an agent would otherwise make differently every time, or by making a failure loud that would otherwise pass green. Write safety bounds what an agent can reach outside the project in front of it, the review loop closes before anything merges, and the audit proves the result instead of accepting the agent's report of it. Autonomy extends exactly as far as the verification reaches. + +**Nothing here is finished.** Every rule traces to a specific failure, nearly all of them observed in this fleet rather than imagined, and a procedure that lets a new one through is corrected as part of the work that found it. The ground truth improves by being used. + +This repo is the single home for those rules, a machine-readable spec they are checked against, a registry of the projects, and an audit-agent instruction set. It ships no application code. Each project owns its own implementation and is **audited** against the ground truth here, to the letter (exact file, section, or config) or to intent (an equivalent outcome). + +- **[AGENTS.md][agents]** - the agent entry point: context and delegation rules, plus the map from a task to the section that governs it. +- **[GOVERNANCE.md][governance]** - cross-cutting rules for AI coding agents: git, branching, release model, doc style, the recurring-violation rules (comments, ASCII charset, US spelling, line endings), PR review etiquette, and workflow YAML conventions. +- **[CODESTYLE.md][codestyle]** - code style for .NET and Python. +- **[WORKFLOW.md][workflow]** - the CI/CD workflow contract (behavioral guarantees D1-D9) and its audit methodology. +- **[STANDUP.md][standup]** - how an agent stands a repository up and carries the baseline it is owed. +- **[AUDIT.md][audit]** - how an agent audits a repository against the spec and reports drift. +- **[RESYNC.md][resync]** - how an agent brings an already-stood-up repository back into line, in the order the remedies require. +- **[spec/][spec]** - the machine-readable ground truth: project-type requirements, the file/section baseline, required/forbidden secrets, the host tool contract and its version floors, and the preferred README structure. +- **[registry/repos.json][repos]** - the fleet registry: every project, its type(s), publish mechanism, and status (cataloged or standardization backlog). +- **[repo-config/][repo-config]** - branch rulesets and the apply script (kept out of `.github/`, which is Actions-owned), plus the GitHub setup reference. +- **[host-setup/][host-setup-dir]** - the host guardrail kit, which is per machine rather than per repository. +- **[catalog/][catalog]** - reusable reference snippets (workflow tasks, config exemplars, devcontainers) the audit compares implementations against. +- **[reports/][reports]** - per-repo audit output. +- **[docs/][docs]** - the human setup and reference guides: host prerequisites, SSH signing, devcontainers, and the carry procedures. + +**The words a request is phrased in are defined here.** A repository is asked to audit itself against the hub, or to close the review loop on a pull request, and the phrasing carries the whole instruction, so each term below names the file that answers it and a request using one is a complete instruction rather than a starting point for interpretation. + +- **The hub** - this repository. It is the single authority on what a fleet project is supposed to hold, and every rule naming the hub means this one. +- **The fleet** - the projects listed in [registry/repos.json][repos], each audited downward against the ground truth here rather than negotiating its own. +- **Stand a repository up** - carry the baseline a project is owed for its declared types and workflow model, per [STANDUP.md][standup]. An absent file is a baseline that never arrived rather than drift. +- **Audit a repository** - read a live project against the spec and report its drift, per [AUDIT.md][audit]. The audit never edits what it measures, so a fix is a separate change. +- **Resync a repository** - bring an already-stood-up project up to the current hub, per [RESYNC.md][resync]. It audits for the findings and then applies them in order, which includes deleting what the hub hosts rather than carries. +- **Close the review loop** - request a review on every push, confirm it covered the head commit, triage every finding, reply and resolve, and escalate when stuck, per [GOVERNANCE.md "PR Review Etiquette"][governance-pr-review-etiquette]. +- **Carried against reached** - a project carries the content it is audited against and reaches the machinery that is identical everywhere, per [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling]. + +## What It Achieves + +Keeping a fleet of repositories consistent has always been a tax paid in review attention, and it stops scaling at the point where one person can no longer hold every repo in their head. An agent changes that arithmetic in both directions at once. It can apply a convention across every repository in an afternoon, and it can spread a mistake exactly as fast. What makes the speed worth having is a ground truth an agent can read, a gate that proves the result rather than reporting it, and a boundary naming the decisions that are never the agent's to make. Each objective below is a standing capability, with the machinery that delivers it named so the claim is checkable. + +- **Workflow consistency, by contract rather than by copy.** Every repo satisfies one behavioral CI/CD contract ([WORKFLOW.md][workflow], guarantees D1 to D9) instead of inheriting one YAML file it then edits. The fixed part is the orchestration seam, meaning job names, the ruleset-bound required check, and the artifact handoff. What a repo builds inside that seam is its own, so a Hugo site and a NuGet package satisfy the same contract without pretending to be the same pipeline. +- **Technical consistency that does not depend on anyone remembering it.** One line-ending policy, one comment shape, one character set, one US-English convention, and one config per linter shared by the editor, the CLI, and CI. A rule that holds in review therefore holds on a laptop and in the pipeline, because all three read the same file rather than three copies that drift apart. +- **Best practices promoted once, not re-litigated per repo.** A practice that proves itself becomes fleet law in [GOVERNANCE.md][governance] or [CODESTYLE.md][codestyle] and is carried, rather than being rediscovered and re-argued in the next repository. Every rule here traces to a specific failure that actually happened, which is why the collection is opinionated and small rather than exhaustive. +- **Feedback loops that close on the procedure, not the instance.** [AUDIT.md][audit] reads a live repo and reports drift, the per-repo reports in [reports/][reports] record it, and a repo that cannot be stood up from the docs alone is a documentation defect tracked in the [conformance matrix][matrix]. When a downstream agent hits something the procedure did not cover, the fix lands in the procedure so the next repo never meets it. +- **Onboarding a new language or deployment target is a spec change.** Thirteen project types are declared today in [spec/project-types.json][project-types]. Adding one means declaring its detection, its checks, and the files it carries, then proving a context-free agent can stand it up cold. No fleet-wide rewrite, and no per-repo improvisation. +- **Re-deployment that is measured and traceable.** Versions come from git history through NBGV rather than a hand-edited number, a release is always a deliberate act and never a side effect of a merge, and staleness is detected by **content hash against the hub's own past revisions**, so the audit can say whether a repo is behind the canonical or has forked it. A version stamp is a claim a repo can keep while editing the content underneath, so it is never trusted for that answer. +- **Every carried unit declares how much freedom it grants.** This is the distinction that makes the whole thing tolerable to work in, and it is a field on each [spec/files.json][files] entry rather than something a reader infers from the file's shape. An entry that names no level takes `presence`, the most permissive one, so silence grants freedom rather than withholding it: + + | Level | The obligation | Who owns the content | + | --- | --- | --- | + | `verbatim` | Byte-identical to canonical, after governed normalization | The hub. A paraphrase is a defect, not an adaptation. | + | `interface` | Honor a named contract, checked by name and wiring | The repo owns the body entirely. | + | `intent` | Reach the same outcome, judged by meaning | The repo owns the wording and shape. | + | `presence` | The unit exists | The repo owns all of it. | + +- **The human contributes where domain expertise is decisive, and only there.** The maintainer keeps what an agent cannot know or must not decide: creating a repository, granting a write outside the owner boundary, changing a ruleset, approving every merge, and every judgment about the domain a repo actually serves. The agent takes the mechanical scale-out, which is the part that does not benefit from human attention and degrades under it. A repo's own knowledge also has a declared destination rather than an improvised one, chosen by what the content is: `CODESTYLE.md` for conventions beyond the carried rules, `ARCHITECTURE.md` for how a code repo is built, `OPERATIONS.md` for how a live-service repo is run, and `TODO.md` for its backlog. Which of those a repo carries follows from what it is, so this hub holds the two that apply to it. Domain expertise therefore lands somewhere declared instead of being diluted into a carried file that the next re-vendor overwrites. + +## How This Repo Operates + +ProjectTemplate follows the same model it documents, and audits its own rules against itself (it classifies as the source-only project type in [WORKFLOW.md][workflow]). + +The doors a session enters through, and where each leads, matching the "Getting Started" table above: + +```mermaid +flowchart TD + start["new session: human via this README, or agent via AGENTS.md Fleet Bootstrap"] + start -->|"set up a machine"| hostsetup["host-setup/: guardrails, then fleet skills"] + start -->|"repo does not exist yet"| standup["STANDUP.md"] + start -->|"repo exists, in or out of conformance"| resync["RESYNC.md"] + hostsetup --> standup + standup --> audit["AUDIT.md: verify"] + resync --> audit + audit -->|"blocked by a rule"| diverging["Diverging From a Rule"] + audit -->|"gap in the rules or docs"| issues["Questions or Issues"] +``` + +The full entry-point map behind these doors, with the gap register and the roadmap that closes it, is [docs/fleet-map.md][fleet-map]. + +Within this repo, day-to-day development follows the same branching, CI, review, and release model it documents for the fleet: + +- **Branching.** Persistent `main` and `develop`, each with its own ruleset. This repo uses the default `release` workflow model: commit on feature branches only, feature branch to `develop` is squash-merged, `develop` to `main` is a merge commit, and `develop` is forward-only (no `main -> develop` back-merges). Live-service config repos instead use the `operational` model (registry `workflowModel`), with direct signed commits to `develop`, promoted to `main` by an occasional PR. See [GOVERNANCE.md "Branching Model"][governance-branching-model]. +- **CI is lint-only.** There is no build or unit test. The PR gate runs markdownlint, cspell, JSON validation (`jq` parses `registry/`, `spec/`, and `repo-config/`, plus the `spec/validate.py` cross-reference and shape checks), and actionlint, and exposes the ruleset-bound `Check pull request workflow status job` aggregator. The same lint configs (`.markdownlint-cli2.jsonc`, `cspell.json`) drive the editor extensions, the CLI, and CI. +- **Review loop.** Every PR is reviewed by GitHub Copilot, and the agent drives the review loop to green and merges only with explicit maintainer permission. See [GOVERNANCE.md "PR Review Etiquette"][governance-pr-review-etiquette]. +- **Release.** A `develop -> main` merge is promoted through a GitHub release (tag plus a source zip, README, and LICENSE). Versioning is NBGV-driven from [version.json][version]. See [WORKFLOW.md][workflow]. + +## Using This Repo + +Four things are deployed from here, and they land in different places. The host guardrails install once per machine, the fleet skills install once per user on that machine, the baseline is carried once per repository, and the audit is run whenever a repository changes materially. Do them in that order on a new machine, because the guardrails bound every session that follows and retrofitting them means the sessions in between ran unguarded. + +### Deploy the Host Guardrails + +The guardrails are the one component that is installed rather than read, and they are **host state rather than repository content**, because they have to cover ad-hoc sessions in no project at all. They deny a mis-targeted GitHub write under your identity, and a git operation that would only land by bypassing a branch rule. + +```shell +host-setup/agent-safety/install.sh # Linux, WSL, macOS +``` + +```powershell +.\host-setup\agent-safety\install.ps1 # Windows, and the .\ prefix is required +``` + +Restart Claude Code sessions on the machine afterward so the hook and the `CLAUDE.md` blocks load. The installer is idempotent, so re-running it is also how a machine picks up an upstream change to the guard. What it installs, how to verify it, and what it deliberately does not catch are in [`host-setup/agent-safety/README.md`][agent-safety], and the surrounding host prerequisites (git identity, SSH signing, `gh`, `docker`, `uv`) are in [`docs/host-setup.md`][host-setup]. + +### Install the Fleet Skills + +The skills are the per-topic rules packaged so they surface in an agent session by trigger, instead of being re-read from the law docs each time. They install once per user per machine, from a hub checkout, and land beside the guardrails rather than in any repository: + +```shell +python3 scripts/skills_install.py # or the scripts/skills_install.sh / .ps1 wrapper +python3 scripts/skills_install.py --report # read-only: is this machine current? +``` + +A host stood up end to end by the [`host-setup/`][host-setup-dir] bootstrap gets this step at the end of its host mode, so a fresh machine finishes with the tools, the identity, and the skills together. [`docs/host-setup.md`][host-setup] "Fleet Skills Install" carries the details, including how the install degrades where the `claude` CLI is absent. + +### Carry the Rules Into a Repository + +A repository that does not exist yet is stood up with [`STANDUP.md`][standup], which is ordered rather than a menu: verify commit identity and signing before the first commit, hand the maintainer what only they can supply, classify the repo and write its [registry][repos] entry, carry the instruction set before authoring anything of your own, then carry the remaining baseline, the workflows, and the settings. The two steps with a closing window are first, because signing has to be live before the first commit and the rules have to be loaded before the first authored file. + +A repository that already exists is measured with [`AUDIT.md`][audit] instead. The audit is read-only and ends in a report under [reports/][reports], so nothing is changed by measuring, and applying what it found is a separate reviewable change per its section 10. Onboarding is complete when the repo passes, or carries a committed report plus a tracking issue for the residual deltas, per [GOVERNANCE.md "Repository Onboarding and Conformance"][governance-repository-onboarding-and-conformance]. + +A repository that is stood up already and has fallen behind is resynced with [`RESYNC.md`][resync], which is the third entry point and the one a request to sync a repository with the hub resolves to. It runs the audit for the findings and then applies them in an order that matters: the rules first, because they govern what comes after them, then the deletions, because a re-vendor would otherwise refresh a file that is about to go, then the re-vendors, the workflow contracts, and the repository configuration. It also states what the measurement cannot see, since a carried file at `intent` fidelity is checked for presence alone and a hub revision inside one raises no finding at all. + +The mechanical helpers that go with those procedures are documented beside them: [`docs/repo-config-carry.md`][repo-config-carry] for branch rulesets and repository settings, and [`docs/content-import.md`][content-import] for importing existing content into a new repo. + +### Adopting Outside This Fleet + +The rules, the spec, and the procedures are readable and reusable by anyone, and the guardrail kit installs on any host. What does not transfer is the registry, since [registry/repos.json][repos] lists this fleet's projects, and standing a repository up writes an entry in it. Adopting outside this fleet therefore means running your own hub, forked or copied, holding your own registry and your own reports, with `AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, `WORKFLOW.md`, and `spec/` adapted to what your projects actually are. There is no supported mode where an outside repository points at this hub's registry. + +## Diverging From a Rule + +A rule that cannot be diverged from is a rule people work around silently, which is worse than the divergence, so both kinds of exception have a declared channel. The two kinds are genuinely different: one is a repository not matching a carried unit, and the other is a host guard denying a write. Neither is granted by editing the thing that blocked you. + +### A Repository Diverging From a Carried Unit + +**Check the unit's fidelity level first, because most apparent divergences are not divergences at all.** Each [spec/files.json][files] entry declares one of `verbatim`, `interface`, `intent`, or `presence`, defaulting to `presence`, and the two permissive levels already hand the repo its content outright. A repo rewriting a `presence` or `intent` file to suit itself is exercising the freedom the level grants rather than breaking a rule, and the audit asserts nothing beyond presence for it. See [spec/fidelity-model.md][fidelity-model] for which unit sits where and why. + +**A divergence from a `verbatim` or `interface` unit is recorded, not hidden.** The ledger is [spec/divergences.json][divergences], where each entry names the path, the repos, a disposition, and a reason, and `spec/fidelity_honesty.py --report` joins it against live fleet reality to regenerate [reports/divergences.md][divergences-report]. A divergence recorded as `accepted` is a legitimate permanent one and no further action is owed. A live divergence with no entry renders as `UNTRIAGED`, which is the point: the audit does not care whether a difference is deliberate, only whether someone decided about it. Edit the ledger and regenerate the report, never the report. + +**A rule that is wrong for many repositories is a hub defect rather than a per-repo exception.** [AUDIT.md][audit] section 9 says so directly, that a repeated letter miss many repos share is a signal the spec needs adjusting, so raise it here instead of accumulating one exception per repository. + +### A Write the Host Guard Denies + +The guard denies a `gh` write whose explicit target sits under an owner other than the checkout's `origin` owner, which is the shape that once put a stray comment on a stranger's repository. Sibling repositories under the same owner are allowed, so the denial appears only on a write that leaves the owner, and the common case that raises it is a fork, where `origin` is yours and `upstream` is the project you forked from. + +The only way past it is a grant the maintainer makes **outside the session**, in `GH_WRITE_GUARD_ALLOW`. It is deliberately not something an agent can do for itself once blocked, so an inline `GH_WRITE_GUARD_ALLOW=owner/repo gh ...` prefix and an `export` inside a shell call both leave the write denied. The worked example, the file the grant goes in, and how to confirm one took effect are in [`docs/host-setup.md` "Granting a Write the Guard Denies"][host-setup-granting-a-write-the-guard-denies]. + +## Rules + +A human-readable index of the rules agents enforce, implement, and audit. The authority for each is [GOVERNANCE.md][governance], [CODESTYLE.md][codestyle], and [WORKFLOW.md][workflow]. The machine-checkable form lives in [spec/][spec]. + +### Always + +- Sign every commit (SSH or GPG). +- Branch feature -> develop (squash) -> main (merge commit), and develop is forward-only. +- Drive every PR through the Copilot review loop and merge only with maintainer approval. +- Write US English and ASCII only (no em-dash, straight quotes). +- Write docs and comments in the present tense, describing only the current state, never as a change from a prior one. +- Keep comments concise and only for the non-obvious, and never grow them on edit. +- Follow `.editorconfig` line endings (LF default, CRLF for `.bat`/`.cmd`) and preserve a file's endings on edit. +- One logical paragraph per line, with a trailing `\` for an intentional hard break. +- Pin every GitHub Action to a commit SHA with a version comment. +- Share one lint config per tool across the editor, the CLI, and CI. +- Run the repo's whole lint gate before pushing, not just the parts that look relevant. +- Make gates fail loud, since a gate that stops gating must error or annotate, never pass silently. +- Favor VS Code tasks and launch configs for building, running, and testing over ad-hoc shell scripts. + +### Never + +- Never force-push or rewrite shared history. +- Never treat a merge as a release. Publishing is a separate, explicit step. +- Never blanket-delete a workflow run's artifacts. +- Never store a static key when OIDC Trusted Publishing is available. + +### If a C# Project + +- Carry the shared `[*.cs]` block in `.editorconfig` and build with zero warnings. + +### If a Python Project + +- Configure ruff and a type checker in `pyproject.toml`, either pyright strict or mypy in CI with pyright editor-only. Whichever runs in CI is the gate. + +### If Both C# and Python + +- Both sections above apply, and a repo can be both (a C# app plus a Python subtree). The Python is either a full uv project (`uv.lock`, `uv run`) or a stdlib-only `uvx` scripts subtree (no `uv.lock`, `pyproject.toml` carries lint/type config only). See [CODESTYLE.md][codestyle] "Two profiles". + +### If Publishing a Package (NuGet or PyPI) + +- Publish via OIDC Trusted Publishing, never a stored API key. + +### If a Docker Image + +- Cache layers to a registry tag (never `type=gha`) and publish the size-limited Docker Hub README separately. + +### For a README or Human-Facing Doc + +- Follow the section order in [spec/readme-structure.md][readme-structure] and put every URI as a grouped, alphabetized reference link at the bottom. + +### For Workflows + +- Make GitHub Actions satisfy the [WORKFLOW.md][workflow] contract (guarantees D1-D9), which the audit verifies. + +## Questions or Issues + +File everything at [Issues][issues-link], including a defect in a rule, a procedure that did not survive contact with a real repository, and a proposal for a new rule. Use [Discussions][discussions-link] for an open question that is not yet a defect. This repo ships no application code, so an issue here is about the rules, the spec, the procedures, or the tooling under [`scripts/`][scripts] and [`spec/`][spec], never about a downstream project's behavior, which belongs on that project's own tracker. + +An issue is most useful when it names the ground truth it disagrees with, so include the file and section that states the rule, the repository and branch where the problem was observed, and what you expected instead. A finding measured against a specific commit is worth more than one measured against a memory of the docs, per [GOVERNANCE.md "Verification Discipline"][governance-verification-discipline]. + +Two kinds of report are worth calling out because they are the ones that improve the procedures rather than one repository: + +- **A procedure that could not be followed cold.** The onboarding docs are sufficient only when a context-free agent can stand a repo shape up from them alone, so a step that needed knowledge the docs never gave is a documentation defect tracked in the [conformance matrix][matrix] and fixed here rather than worked around per repo. +- **A rule that many repositories miss the same way.** That is a signal the spec needs adjusting rather than a queue of per-repo exceptions, per [AUDIT.md][audit] section 9. + +Issues are also filed here **by agents working in downstream repositories**, which is the normal path rather than an exception, since the agent that hit the gap is the one holding the evidence for it. + +## Development Environment Setup + +Contributors sign every commit. See [docs/ssh-signing.md][ssh-signing] for SSH commit-signing setup, [docs/host-setup.md][host-setup] for host prerequisites, and [docs/devcontainer.md][devcontainer] for devcontainer SSH-agent forwarding. Run the linters before pushing (see [GOVERNANCE.md "Running the Linters Locally"][governance-running-the-linters-locally-known-working-invocations]). + +Changes land the same way every fleet change does: a feature branch, a squash merge into `develop`, a Copilot review loop driven to green, and a merge only with the maintainer's explicit approval. The backlog is [`TODO.md`][todo], which holds the work that is ready to pick up along with the reasoning behind each item, so read it before proposing something it already covers. + +## 3rd Party Tools + +The third-party tools, libraries, and actions this project depends on. + +| Tool | Role | +| --- | --- | +| [cspell][cspell-link] | Spell checker. | +| [editorconfig-checker][editorconfig-checker-link] | Line-ending and whitespace linter. | +| [GitHub Actions][github-actions-link] | CI and automation runner. | +| [GitHub Dependabot][dependabot-link] | Dependency update bot. | +| [Markdown All in One][markdown-all-in-one-link] | Markdown editing extension. | +| [markdownlint-cli2][markdownlint-link] | Markdown linter. | +| [Nerdbank.GitVersioning][nbgv-link] | Version computation from git height. | + +## License + +Licensed under the [MIT License][license]\ +![License][license-shield] + +<!-- Sections --> + +[diverging-from-a-rule]: #diverging-from-a-rule +[questions-or-issues]: #questions-or-issues +[what-it-achieves]: #what-it-achieves +[what-this-repo-is]: #what-this-repo-is + +<!-- Shields --> + +[github-pre-release-shield]: https://img.shields.io/github/v/release/ptr727/ProjectTemplate?include_prereleases&label=GitHub%20Pre-Release&logo=github +[github-release-shield]: https://img.shields.io/github/v/release/ptr727/ProjectTemplate?logo=github&label=GitHub%20Release +[last-commit-shield]: https://img.shields.io/github/last-commit/ptr727/ProjectTemplate?logo=github&label=Last%20Commit +[license-shield]: https://img.shields.io/github/license/ptr727/ProjectTemplate?label=License +[releases-build-shield]: https://img.shields.io/github/actions/workflow/status/ptr727/ProjectTemplate/publish-release.yml?event=schedule&logo=github&label=Releases%20Build + +<!-- Distribution --> + +[actions-link]: https://github.com/ptr727/ProjectTemplate/actions +[commits-link]: https://github.com/ptr727/ProjectTemplate/commits +[discussions-link]: https://github.com/ptr727/ProjectTemplate/discussions +[github-link]: https://github.com/ptr727/ProjectTemplate +[issues-link]: https://github.com/ptr727/ProjectTemplate/issues +[releases-link]: https://github.com/ptr727/ProjectTemplate/releases + +<!-- Repo --> + +[agent-safety]: ./host-setup/agent-safety/README.md +[agents]: ./AGENTS.md +[audit]: ./AUDIT.md +[catalog]: ./catalog/ +[codestyle]: ./CODESTYLE.md +[content-import]: ./docs/content-import.md +[devcontainer]: ./docs/devcontainer.md +[divergences]: ./spec/divergences.json +[divergences-report]: ./reports/divergences.md +[docs]: ./docs/ +[fidelity-model]: ./spec/fidelity-model.md +[files]: ./spec/files.json +[fleet-map]: ./docs/fleet-map.md +[governance]: ./GOVERNANCE.md +[governance-branching-model]: ./GOVERNANCE.md#branching-model +[governance-hub-hosted-tooling]: ./GOVERNANCE.md#hub-hosted-tooling +[governance-pr-review-etiquette]: ./GOVERNANCE.md#pr-review-etiquette +[governance-repository-onboarding-and-conformance]: ./GOVERNANCE.md#repository-onboarding-and-conformance +[governance-running-the-linters-locally-known-working-invocations]: ./GOVERNANCE.md#running-the-linters-locally-known-working-invocations +[governance-verification-discipline]: ./GOVERNANCE.md#verification-discipline +[history]: ./HISTORY.md +[host-setup]: ./docs/host-setup.md +[host-setup-dir]: ./host-setup/ +[host-setup-granting-a-write-the-guard-denies]: ./docs/host-setup.md#granting-a-write-the-guard-denies +[license]: ./LICENSE +[matrix]: ./reports/conformance-matrix.md +[project-types]: ./spec/project-types.json +[readme-structure]: ./spec/readme-structure.md +[repo-config]: ./repo-config/ +[repo-config-carry]: ./docs/repo-config-carry.md +[reports]: ./reports/ +[repos]: ./registry/repos.json +[resync]: ./RESYNC.md +[scripts]: ./scripts/ +[spec]: ./spec/ +[ssh-signing]: ./docs/ssh-signing.md +[standup]: ./STANDUP.md +[todo]: ./TODO.md +[version]: ./version.json +[workflow]: ./WORKFLOW.md + +<!-- External --> + +[cspell-link]: https://cspell.org +[dependabot-link]: https://github.com/dependabot +[editorconfig-checker-link]: https://github.com/editorconfig-checker/editorconfig-checker +[github-actions-link]: https://github.com/actions +[markdown-all-in-one-link]: https://marketplace.visualstudio.com/items?itemName=yzhang.markdown-all-in-one +[markdownlint-link]: https://github.com/DavidAnson/markdownlint-cli2 +[nbgv-link]: https://github.com/dotnet/Nerdbank.GitVersioning diff --git a/RESYNC.md b/RESYNC.md index 295130de..841b52b2 100644 --- a/RESYNC.md +++ b/RESYNC.md @@ -1,134 +1,134 @@ -# RESYNC.md - -How an agent brings a repository that is **already stood up** back into line with the current hub. This is the procedure a request to sync a repository with the hub resolves to, and it is the third of three entry points: [`STANDUP.md`][standup] takes a repository from nothing to operational, [`AUDIT.md`][audit] measures one and changes nothing, and this file applies what the measurement found, in an order that matters. - -Resyncing invents nothing. It is [`AUDIT.md`][audit] run for the findings, then each finding applied by the remedy its class already carries. What this file adds is the **order**, the **remedies that remove content rather than update it**, and an honest account of **what a resync cannot detect**, none of which a findings list states on its own. - -```mermaid -flowchart TD - route["0: which state? none / partial / full instruction set"] - route -->|"no repo, no remote"| toStandup["STANDUP.md section 0"] - route -->|"partial instruction set"| toStandup2["STANDUP.md sections 1A, 2"] - route -->|"full instruction set, current or stale"| reach["1: fetch hub main, verify host"] - reach --> measure["2: audit.py, --issue, fidelity_honesty.py --report"] - subgraph apply["3: apply, in this order"] - instr["instruction set"] --> del["deletions"] --> revendor["verbatim re-vendors"] --> iface["interface workflows"] --> settings["settings, rulesets, secrets"] --> intent["intent files, by hand (see 5: what this cannot detect)"] - end - measure --> apply - apply --> ship["6: PR per drift class, review loop, merge, then re-audit"] -``` - -## 0. Route Here Only If the Repository Is Stood Up - -Three states look similar from inside a repository and take different procedures, so establish which one before doing anything. The `AGENTS.md` "Fleet Bootstrap" section carries this same routing in the repository itself, byte-locked, so an agent finds it without knowing this file exists. - -- **No repository, or a local tree with no remote.** [`STANDUP.md`][standup] from section 0. Nothing here applies, because there is nothing to bring into line. -- **A partial instruction set.** [`STANDUP.md`][standup] sections 1A and 2. An absent carried file is a baseline that never arrived, not drift, and the two are fixed differently: a baseline is carried, and drift is converged. Resyncing a repository whose `AGENTS.md` never carried the rule sections would re-vendor sections into a document that was never given them, which reads as drift repair and is really a late standup. -- **A full instruction set, current or stale.** This file. Stale is the normal case and is not a defect, since the hub moves and a repository is a snapshot of the revision it last converged with. - -**The distinction is measured, not assumed.** `spec/audit.py <Repo>` reports an absent carried file as a `LETTER` and a present-but-stale one as a `DRIFT`, so the finding kinds themselves say which procedure the repository is owed. A run that is mostly letters is a repository that needs [`STANDUP.md`][standup]. - -**A few letters are not that, and the ordinary cause is the baseline having grown.** When the manifest gains an entry, every repository in the fleet reports that one file absent on its next audit, all at once and through no fault of its own. That is a file to carry inside a resync, at the point in section 3 where its class belongs, rather than evidence the repository was never stood up. Read the letters before routing on their count: a handful naming files the hub added recently is a resync, and a baseline's worth naming the instruction set is a standup. - -## 1. Reach the Hub, and Verify the Host - -**Fetch a hub checkout of your own immediately before reading it.** A clone is whatever it last fetched rather than the branch it names, so a stale one answers confidently instead of failing, and a resync driven from a stale hub converges a repository onto a revision that is already history. Read `main`, the promoted and gated state, per [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling]. Work only in that checkout rather than in one that another task is using. - -**Verify the host before running any hub tool.** The tools carry version floors, and a host below one does not fail cleanly: it answers `--version`, looks healthy, and produces a wrong answer. Both host defects this fleet has hit were version facts on a tool that was installed. - -```shell -python3 scripts/host_gate.py --repo <path-to-target-checkout> # run from the hub, floors from spec/host-tools.json -``` - -A finding here is a **host** misconfiguration to fix on the machine, or to surface to the maintainer, and never something to patch per repository. [`docs/host-setup.md`][host-setup] is the contract it checks. - -**Pass `--repo`, because the flag is what makes the target's own floors count.** A repository declares a root `host-tools.json` layering over the hub's, tighten-only, per [`scripts/README.md`][scripts], and the gate reads that file relative to `--repo`, which defaults to the working directory. So a bare run from a hub checkout reads the hub's declaration and does not read the target's at all, which means every floor that repository adds goes unapplied. The run reports nothing about the omission either, since it has no way to know a target was intended, so a healthy digest from a bare run is a statement about the fleet floors alone. - -The identity and signing checks in [`STANDUP.md`][standup] section 0 apply to a resync too, since it ends in commits like any other change. Read the `--global` scope explicitly, because inside a repository a local override wins and hides the host setting. - -## 2. Measure, Before Changing Anything - -Run [`AUDIT.md`][audit] end to end. Three commands cover the mechanized part, and they are not interchangeable. The first reports the findings. The second **runs its own audit** and renders those findings as an issue ready to file, so it is a second measurement rather than a view over the first, and the two can disagree if the repository moves between them. The third is a different tool answering a fleet-wide question. The rest of that file is the half no tool evaluates. - -```shell -python3 spec/audit.py <Repo> # the deterministic findings, read at main -python3 spec/audit.py --branch <ref> <Repo> # the same, read at a named ref, for convergence still in flight -python3 spec/audit.py --issue <Repo> # audits again and renders that run as an issue ready to file, so it can differ from the line above -python3 spec/fidelity_honesty.py --report # regenerate reports/divergences.md before using it as a work list -``` - -**The branch is `main`, and the registry field naming it is not a choice a resync makes.** The runner reads the repository's `groundTruthBranch`, and [`AUDIT.md`][audit] section 1 owns what that value may be: `main` is the released and gated state for **both** workflow models, so a field naming `develop` contradicts the procedure rather than configuring it. Every cataloged repository declares `main` or omits the field and takes it as the default, so the first line above reads `main` in every case today. - -**`--branch` is the supported way to read something else, and a resync is when it earns its place.** Convergence lands on a feature branch and reaches `main` only after the maintainer merges, so auditing ground truth mid-resync measures the state you are part-way through replacing. Pointing the run at the branch in flight checks the work before it is promoted rather than after. The registry is not edited, the run stays read-only, and the run stamp names the override so a finding from it cannot be mistaken for one against ground truth. - -**Regenerate the divergence report rather than reading the committed copy.** It is a live pass over each repository's ground-truth branch, and the checked-in file is only as current as its last run, so a stale one hands out a work list measured against a tree that no longer exists. - -**A finding is a snapshot.** Quote the run stamp (`audit run <UTC> | hub <sha>`) in anything derived from the run, and re-run before acting on a finding written earlier, because a repository moves between filing and pickup and a stale list leads to re-fixing what is already fixed. - -## 3. Apply, in This Order - -The order is load-bearing. Each step below either changes the rules the later steps are judged against, or removes something a later step would otherwise refresh. - -1. **The instruction set first.** `AGENTS.md` and `GOVERNANCE.md` verbatim sections, then `CODESTYLE.md` and `WORKFLOW.md`. `AGENTS.md`'s skill-dependency pointer paragraph, naming `scripts/skills_install.py` and where the fleet's Skills live, carries as one more verbatim unit in this same step, not a separate pass. These are the rules for producing every other file, so carrying them last means everything touched beforehand was judged against the previous revision. This is the same closing-window shape as [`STANDUP.md`][standup] section 1A, and the cost of getting it wrong is rework proportional to how much was changed first. **Run the `carried-instruction-file-guard` skill's distinctive-phrase probe before any verbatim re-vendor in this step, every time, without exception.** A diff that looks routine is exactly the shape the AGENTS.md-overwrite incident took, so the probe is not weighed against how routine the request sounds, it runs regardless. `resync-a-repo` packages this whole step, and the rest of this section, as a Skill for a hub-context session driving the resync. -2. **Deletions second, before any re-vendor.** A `hub-only:` finding names a file the hub hosts rather than carries, and its remedy removes the file. Doing it after the re-vendors means refreshing a copy that is about to be deleted, which is wasted work that also reads as a deliberate update in the diff. See section 4, which is the whole of what deletion means here. -3. **Verbatim re-vendors.** Copy the current hub canonical down, whole file or the one named `## heading` region. A finding classified **stale** matches a past hub revision and needs no judgment. One classified **modified** matches no revision, so the repository changed fixed content and the change is read before it is overwritten, since it may be an improvement the hub should adopt instead. -4. **Interface workflows.** Honor the named contract (required jobs, the ruleset-bound check name, the artifact-name handoff) rather than copying bytes. The body is the repository's own. -5. **Settings, rulesets, and secrets.** Run the hub's script against the repository by name, never a carried copy: `repo-config/configure.sh check <owner>/<repo> release|operational`, then `apply` for what it reports. Pass the model explicitly rather than relying on the registry lookup. -6. **Intent files last, and by hand.** See section 5, which states why these carry no mechanical signal at all. - -**Reconcile the registry entry in the same pass.** `status`, `types`, `releaseTrigger`, `workflowModel` and `driftNotes` record reality rather than intent, and a `driftNote` describing work that is now finished is deleted rather than left standing. A note asserting outstanding work in prose ("pending", "not yet", "behind") contradicts a clean audit outright. - -## 4. Deleting Is a Remedy, and It Is the One That Can Destroy Work - -Every other finding in this procedure is satisfied by adding or replacing content. This one is satisfied by removing it, which makes it the only class where acting on a wrong finding loses something. - -The detector is derived rather than listed: the hub's git-tracked paths minus the [`spec/files.json`][files] baseline is what the hub hosts and no repository carries. That means a file dropped from the manifest starts being reported on the next run with no retirement list to maintain, and it also means **the match is on path alone**, so a hit is a candidate and not a verdict. - -```mermaid -flowchart LR - candidate["hub git-tracked path, not in spec/files.json"] --> disp["check spec/divergences.json disposition"] - disp -->|"retire"| delete["delete the file"] - disp -->|"accepted"| keep["keep, closed permanently"] - disp -->|"untriaged"| read["read before touching"] - delete --> sweep["sweep every inbound reference, tree-wide"] - sweep --> fix["re-point, rewrite, or remove, per case"] -``` - -- **Only a `retire` disposition in [`spec/divergences.json`][divergences] authorizes a deletion.** It records that the file is the hub's content with nothing per-repository in it, and what to reach instead. -- **An untriaged hit is read before it is touched.** A repository's own content at a path the hub also uses matches this check while carrying nothing of the hub's. The first fleet-wide run found two: a KiCad tooling document at `scripts/README.md`, and per-repository formatting hooks at `.husky/pre-commit`, each of which shares the path and none of the content. Deleting either would have destroyed work the hub never owned. -- **An `accepted` disposition closes the hit permanently**, whether it is a path collision or a file every repository legitimately owns, such as `LICENSE` and `TODO.md`. -- **Sweep every inbound reference to the path, and count that as part of the deletion.** This governs any deletion rather than only a `hub-only:` finding. The three bullets above turn on a disposition and so belong to this detector, where the sweep turns on nothing: it applies the same way to a file the repository owns and removes for reasons of its own, which is where the fleet first hit it. Deleting the file is one edit and finishing the job usually takes several more, so grep the path tree-wide before deleting and read every hit. **Then read the files whose job is to say what the repo holds, since a grep for the path finds uses of the file and misses descriptions of it.** A layout section calling a deleted script "the apply script" names no path, so it survives every search for one and goes stale silently. The named-path half of this class is mechanized: `scripts/prose_lint.py --check dead-path` reports a Markdown mention of a path git once tracked and the tree no longer holds, continuously rather than only at deletion time, so the manual read here is what covers the description that names no path, which no pattern reaches. This is measured rather than predicted: a resync deleted a carried script, swept its path across the tree, merged, and left `GOVERNANCE.md` "Repository Layout" still describing the directory as holding it. Three shapes appear among the hits and they are fixed differently. A link whose target has an equivalent elsewhere is **re-pointed** at it, the hub's copy being that equivalent for a hub-hosted file, since a pointer that resolves nowhere teaches a reader that a pointer in carried text is decorative. A **runnable command** citing the deleted path is rewritten to the invocation that still works, and this is the one that costs a reader real time, because a documented command fails where a dead link merely disappoints. A mention with no equivalent anywhere is **removed** rather than re-pointed, and removing a reference-style link removes its definition with it, per [GOVERNANCE.md "Documentation Style Conventions"][governance-documentation-style], since an orphaned definition fails the no-unused-defs rule and is a gate failure rather than untidiness. -- **The sweep is measured, not hypothetical, and the second case is the one that proves it is not hub-only.** One repository carries an inline link to `configure.sh` in its `repo-config/README.md`, alongside four commands invoking `./repo-config/configure.sh`, so the retirement of that one file makes five lines of a carried document wrong, two of them commands a reader would run. Another repository's nested `AGENTS.md` was deleted for reasons entirely its own, with no disposition and no hub equivalent, and it took three edits across two files: removing the inline link alone tripped `MD053` on the definition it orphaned, so the delete-the-file-only path failed CI rather than merely breaking prose. - -## 5. What a Resync Cannot Detect - -State this rather than letting a clean run imply more than it earned. A carried file at `intent` fidelity is checked for **presence plus a staleness advisory**, per [`spec/fidelity-model.md`][fidelity-model]: its content is never judged, and a copy whose hub canonical changed after the copy's own last commit is reported as possibly trailing, at drift. That covers `CODESTYLE.md`, `WORKFLOW.md`, the carried `AUDIT.md`, `.github/copilot-instructions.md`, `.editorconfig`, `.gitattributes`, `cspell.json` and `version.json`. - -The advisory is a hint rather than proof, since a copy touched after the hub change without actually reconciling still reads current. Two things follow. Read the hub's own history for those files when a resync is meant to be thorough, rather than trusting the advisory to raise them. And treat `spec/fidelity_honesty.py`'s promotion candidates as the structural fix: an `intent` unit that is content-identical fleet-wide can become `verbatim` and gain drift detection for free, which is the class that hid the `configure.sh` drift for as long as it did. - -The other half is section 4 of [`AUDIT.md`][audit]: no check belonging to a project type in [`spec/project-types.json`][project-types] is mechanized at all. A clean tool run is evidence for the deterministic subset, no evidence for a type's checks, and partial evidence across the cross-cutting dimensions. - -## 6. Ship It - -- **One focused pull request per drift class**, branched from the target's `develop`, cross-referencing the finding it closes. A sprawling all-drifts pull request draws many review rounds and never feels done. -- **Never push a fix directly to a protected branch**, and never hand-edit a target outside a pull request. An operational repository commits to `develop` directly by design, and a conformance change is still a reviewable change. -- **Close the review loop.** Request a review on every push, confirm it covered the head commit, and answer and resolve every thread, per [GOVERNANCE.md "PR Review Etiquette"][governance-pr-review-etiquette] and the [Copilot review runbook][copilot-runbook]. -- **The maintainer merges.** The agent drives to green and stops. -- **Fix systemic drift in the hub instead.** Where many repositories share a drift, fix the rule or add a check here and let a re-audit re-flag it, rather than hand-patching each repository for a shared cause. - -**Done means measured, not applied.** Re-run the audit after the merge and commit the report, because a convergence asserted without a report is a convergence nobody can check. - -<!-- Repo --> - -[audit]: ./AUDIT.md -[copilot-runbook]: ./.github/copilot-instructions.md -[divergences]: ./spec/divergences.json -[fidelity-model]: ./spec/fidelity-model.md -[files]: ./spec/files.json -[governance-documentation-style]: ./GOVERNANCE.md#documentation-style-conventions -[governance-hub-hosted-tooling]: ./GOVERNANCE.md#hub-hosted-tooling -[governance-pr-review-etiquette]: ./GOVERNANCE.md#pr-review-etiquette -[host-setup]: ./docs/host-setup.md -[project-types]: ./spec/project-types.json -[scripts]: ./scripts/README.md -[standup]: ./STANDUP.md +# RESYNC.md + +How an agent brings a repository that is **already stood up** back into line with the current hub. This is the procedure a request to sync a repository with the hub resolves to, and it is the third of three entry points: [`STANDUP.md`][standup] takes a repository from nothing to operational, [`AUDIT.md`][audit] measures one and changes nothing, and this file applies what the measurement found, in an order that matters. + +Resyncing invents nothing. It is [`AUDIT.md`][audit] run for the findings, then each finding applied by the remedy its class already carries. What this file adds is the **order**, the **remedies that remove content rather than update it**, and an honest account of **what a resync cannot detect**, none of which a findings list states on its own. + +```mermaid +flowchart TD + route["0: which state? none / partial / full instruction set"] + route -->|"no repo, no remote"| toStandup["STANDUP.md section 0"] + route -->|"partial instruction set"| toStandup2["STANDUP.md sections 1A, 2"] + route -->|"full instruction set, current or stale"| reach["1: fetch hub main, verify host"] + reach --> measure["2: audit.py, --issue, fidelity_honesty.py --report"] + subgraph apply["3: apply, in this order"] + instr["instruction set"] --> del["deletions"] --> revendor["verbatim re-vendors"] --> iface["interface workflows"] --> settings["settings, rulesets, secrets"] --> intent["intent files, by hand (see 5: what this cannot detect)"] + end + measure --> apply + apply --> ship["6: PR per drift class, review loop, merge, then re-audit"] +``` + +## 0. Route Here Only If the Repository Is Stood Up + +Three states look similar from inside a repository and take different procedures, so establish which one before doing anything. The `AGENTS.md` "Fleet Bootstrap" section carries this same routing in the repository itself, byte-locked, so an agent finds it without knowing this file exists. + +- **No repository, or a local tree with no remote.** [`STANDUP.md`][standup] from section 0. Nothing here applies, because there is nothing to bring into line. +- **A partial instruction set.** [`STANDUP.md`][standup] sections 1A and 2. An absent carried file is a baseline that never arrived, not drift, and the two are fixed differently: a baseline is carried, and drift is converged. Resyncing a repository whose `AGENTS.md` never carried the rule sections would re-vendor sections into a document that was never given them, which reads as drift repair and is really a late standup. +- **A full instruction set, current or stale.** This file. Stale is the normal case and is not a defect, since the hub moves and a repository is a snapshot of the revision it last converged with. + +**The distinction is measured, not assumed.** `spec/audit.py <Repo>` reports an absent carried file as a `LETTER` and a present-but-stale one as a `DRIFT`, so the finding kinds themselves say which procedure the repository is owed. A run that is mostly letters is a repository that needs [`STANDUP.md`][standup]. + +**A few letters are not that, and the ordinary cause is the baseline having grown.** When the manifest gains an entry, every repository in the fleet reports that one file absent on its next audit, all at once and through no fault of its own. That is a file to carry inside a resync, at the point in section 3 where its class belongs, rather than evidence the repository was never stood up. Read the letters before routing on their count: a handful naming files the hub added recently is a resync, and a baseline's worth naming the instruction set is a standup. + +## 1. Reach the Hub, and Verify the Host + +**Fetch a hub checkout of your own immediately before reading it.** A clone is whatever it last fetched rather than the branch it names, so a stale one answers confidently instead of failing, and a resync driven from a stale hub converges a repository onto a revision that is already history. Read `main`, the promoted and gated state, per [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling]. Work only in that checkout rather than in one that another task is using. + +**Verify the host before running any hub tool.** The tools carry version floors, and a host below one does not fail cleanly: it answers `--version`, looks healthy, and produces a wrong answer. Both host defects this fleet has hit were version facts on a tool that was installed. + +```shell +python3 scripts/host_gate.py --repo <path-to-target-checkout> # run from the hub, floors from spec/host-tools.json +``` + +A finding here is a **host** misconfiguration to fix on the machine, or to surface to the maintainer, and never something to patch per repository. [`docs/host-setup.md`][host-setup] is the contract it checks. + +**Pass `--repo`, because the flag is what makes the target's own floors count.** A repository declares a root `host-tools.json` layering over the hub's, tighten-only, per [`scripts/README.md`][scripts], and the gate reads that file relative to `--repo`, which defaults to the working directory. So a bare run from a hub checkout reads the hub's declaration and does not read the target's at all, which means every floor that repository adds goes unapplied. The run reports nothing about the omission either, since it has no way to know a target was intended, so a healthy digest from a bare run is a statement about the fleet floors alone. + +The identity and signing checks in [`STANDUP.md`][standup] section 0 apply to a resync too, since it ends in commits like any other change. Read the `--global` scope explicitly, because inside a repository a local override wins and hides the host setting. + +## 2. Measure, Before Changing Anything + +Run [`AUDIT.md`][audit] end to end. Three commands cover the mechanized part, and they are not interchangeable. The first reports the findings. The second **runs its own audit** and renders those findings as an issue ready to file, so it is a second measurement rather than a view over the first, and the two can disagree if the repository moves between them. The third is a different tool answering a fleet-wide question. The rest of that file is the half no tool evaluates. + +```shell +python3 spec/audit.py <Repo> # the deterministic findings, read at main +python3 spec/audit.py --branch <ref> <Repo> # the same, read at a named ref, for convergence still in flight +python3 spec/audit.py --issue <Repo> # audits again and renders that run as an issue ready to file, so it can differ from the line above +python3 spec/fidelity_honesty.py --report # regenerate reports/divergences.md before using it as a work list +``` + +**The branch is `main`, and the registry field naming it is not a choice a resync makes.** The runner reads the repository's `groundTruthBranch`, and [`AUDIT.md`][audit] section 1 owns what that value may be: `main` is the released and gated state for **both** workflow models, so a field naming `develop` contradicts the procedure rather than configuring it. Every cataloged repository declares `main` or omits the field and takes it as the default, so the first line above reads `main` in every case today. + +**`--branch` is the supported way to read something else, and a resync is when it earns its place.** Convergence lands on a feature branch and reaches `main` only after the maintainer merges, so auditing ground truth mid-resync measures the state you are part-way through replacing. Pointing the run at the branch in flight checks the work before it is promoted rather than after. The registry is not edited, the run stays read-only, and the run stamp names the override so a finding from it cannot be mistaken for one against ground truth. + +**Regenerate the divergence report rather than reading the committed copy.** It is a live pass over each repository's ground-truth branch, and the checked-in file is only as current as its last run, so a stale one hands out a work list measured against a tree that no longer exists. + +**A finding is a snapshot.** Quote the run stamp (`audit run <UTC> | hub <sha>`) in anything derived from the run, and re-run before acting on a finding written earlier, because a repository moves between filing and pickup and a stale list leads to re-fixing what is already fixed. + +## 3. Apply, in This Order + +The order is load-bearing. Each step below either changes the rules the later steps are judged against, or removes something a later step would otherwise refresh. + +1. **The instruction set first.** `AGENTS.md` and `GOVERNANCE.md` verbatim sections, then `CODESTYLE.md` and `WORKFLOW.md`. `AGENTS.md`'s skill-dependency pointer paragraph, naming `scripts/skills_install.py` and where the fleet's Skills live, carries as one more verbatim unit in this same step, not a separate pass. These are the rules for producing every other file, so carrying them last means everything touched beforehand was judged against the previous revision. This is the same closing-window shape as [`STANDUP.md`][standup] section 1A, and the cost of getting it wrong is rework proportional to how much was changed first. **Run the `carried-instruction-file-guard` skill's distinctive-phrase probe before any verbatim re-vendor in this step, every time, without exception.** A diff that looks routine is exactly the shape the AGENTS.md-overwrite incident took, so the probe is not weighed against how routine the request sounds, it runs regardless. `resync-a-repo` packages this whole step, and the rest of this section, as a Skill for a hub-context session driving the resync. +2. **Deletions second, before any re-vendor.** A `hub-only:` finding names a file the hub hosts rather than carries, and its remedy removes the file. Doing it after the re-vendors means refreshing a copy that is about to be deleted, which is wasted work that also reads as a deliberate update in the diff. See section 4, which is the whole of what deletion means here. +3. **Verbatim re-vendors.** Copy the current hub canonical down, whole file or the one named `## heading` region. A finding classified **stale** matches a past hub revision and needs no judgment. One classified **modified** matches no revision, so the repository changed fixed content and the change is read before it is overwritten, since it may be an improvement the hub should adopt instead. +4. **Interface workflows.** Honor the named contract (required jobs, the ruleset-bound check name, the artifact-name handoff) rather than copying bytes. The body is the repository's own. +5. **Settings, rulesets, and secrets.** Run the hub's script against the repository by name, never a carried copy: `repo-config/configure.sh check <owner>/<repo> release|operational`, then `apply` for what it reports. Pass the model explicitly rather than relying on the registry lookup. +6. **Intent files last, and by hand.** See section 5, which states why these carry no mechanical signal at all. + +**Reconcile the registry entry in the same pass.** `status`, `types`, `releaseTrigger`, `workflowModel` and `driftNotes` record reality rather than intent, and a `driftNote` describing work that is now finished is deleted rather than left standing. A note asserting outstanding work in prose ("pending", "not yet", "behind") contradicts a clean audit outright. + +## 4. Deleting Is a Remedy, and It Is the One That Can Destroy Work + +Every other finding in this procedure is satisfied by adding or replacing content. This one is satisfied by removing it, which makes it the only class where acting on a wrong finding loses something. + +The detector is derived rather than listed: the hub's git-tracked paths minus the [`spec/files.json`][files] baseline is what the hub hosts and no repository carries. That means a file dropped from the manifest starts being reported on the next run with no retirement list to maintain, and it also means **the match is on path alone**, so a hit is a candidate and not a verdict. + +```mermaid +flowchart LR + candidate["hub git-tracked path, not in spec/files.json"] --> disp["check spec/divergences.json disposition"] + disp -->|"retire"| delete["delete the file"] + disp -->|"accepted"| keep["keep, closed permanently"] + disp -->|"untriaged"| read["read before touching"] + delete --> sweep["sweep every inbound reference, tree-wide"] + sweep --> fix["re-point, rewrite, or remove, per case"] +``` + +- **Only a `retire` disposition in [`spec/divergences.json`][divergences] authorizes a deletion.** It records that the file is the hub's content with nothing per-repository in it, and what to reach instead. +- **An untriaged hit is read before it is touched.** A repository's own content at a path the hub also uses matches this check while carrying nothing of the hub's. The first fleet-wide run found two: a KiCad tooling document at `scripts/README.md`, and per-repository formatting hooks at `.husky/pre-commit`, each of which shares the path and none of the content. Deleting either would have destroyed work the hub never owned. +- **An `accepted` disposition closes the hit permanently**, whether it is a path collision or a file every repository legitimately owns, such as `LICENSE` and `TODO.md`. +- **Sweep every inbound reference to the path, and count that as part of the deletion.** This governs any deletion rather than only a `hub-only:` finding. The three bullets above turn on a disposition and so belong to this detector, where the sweep turns on nothing: it applies the same way to a file the repository owns and removes for reasons of its own, which is where the fleet first hit it. Deleting the file is one edit and finishing the job usually takes several more, so grep the path tree-wide before deleting and read every hit. **Then read the files whose job is to say what the repo holds, since a grep for the path finds uses of the file and misses descriptions of it.** A layout section calling a deleted script "the apply script" names no path, so it survives every search for one and goes stale silently. The named-path half of this class is mechanized: `scripts/prose_lint.py --check dead-path` reports a Markdown mention of a path git once tracked and the tree no longer holds, continuously rather than only at deletion time, so the manual read here is what covers the description that names no path, which no pattern reaches. This is measured rather than predicted: a resync deleted a carried script, swept its path across the tree, merged, and left `GOVERNANCE.md` "Repository Layout" still describing the directory as holding it. Three shapes appear among the hits and they are fixed differently. A link whose target has an equivalent elsewhere is **re-pointed** at it, the hub's copy being that equivalent for a hub-hosted file, since a pointer that resolves nowhere teaches a reader that a pointer in carried text is decorative. A **runnable command** citing the deleted path is rewritten to the invocation that still works, and this is the one that costs a reader real time, because a documented command fails where a dead link merely disappoints. A mention with no equivalent anywhere is **removed** rather than re-pointed, and removing a reference-style link removes its definition with it, per [GOVERNANCE.md "Documentation Style Conventions"][governance-documentation-style], since an orphaned definition fails the no-unused-defs rule and is a gate failure rather than untidiness. +- **The sweep is measured, not hypothetical, and the second case is the one that proves it is not hub-only.** One repository carries an inline link to `configure.sh` in its `repo-config/README.md`, alongside four commands invoking `./repo-config/configure.sh`, so the retirement of that one file makes five lines of a carried document wrong, two of them commands a reader would run. Another repository's nested `AGENTS.md` was deleted for reasons entirely its own, with no disposition and no hub equivalent, and it took three edits across two files: removing the inline link alone tripped `MD053` on the definition it orphaned, so the delete-the-file-only path failed CI rather than merely breaking prose. + +## 5. What a Resync Cannot Detect + +State this rather than letting a clean run imply more than it earned. A carried file at `intent` fidelity is checked for **presence plus a staleness advisory**, per [`spec/fidelity-model.md`][fidelity-model]: its content is never judged, and a copy whose hub canonical changed after the copy's own last commit is reported as possibly trailing, at drift. That covers `CODESTYLE.md`, `WORKFLOW.md`, the carried `AUDIT.md`, `.github/copilot-instructions.md`, `.editorconfig`, `.gitattributes`, `cspell.json` and `version.json`. + +The advisory is a hint rather than proof, since a copy touched after the hub change without actually reconciling still reads current. Two things follow. Read the hub's own history for those files when a resync is meant to be thorough, rather than trusting the advisory to raise them. And treat `spec/fidelity_honesty.py`'s promotion candidates as the structural fix: an `intent` unit that is content-identical fleet-wide can become `verbatim` and gain drift detection for free, which is the class that hid the `configure.sh` drift for as long as it did. + +The other half is section 4 of [`AUDIT.md`][audit]: no check belonging to a project type in [`spec/project-types.json`][project-types] is mechanized at all. A clean tool run is evidence for the deterministic subset, no evidence for a type's checks, and partial evidence across the cross-cutting dimensions. + +## 6. Ship It + +- **One focused pull request per drift class**, branched from the target's `develop`, cross-referencing the finding it closes. A sprawling all-drifts pull request draws many review rounds and never feels done. +- **Never push a fix directly to a protected branch**, and never hand-edit a target outside a pull request. An operational repository commits to `develop` directly by design, and a conformance change is still a reviewable change. +- **Close the review loop.** Request a review on every push, confirm it covered the head commit, and answer and resolve every thread, per [GOVERNANCE.md "PR Review Etiquette"][governance-pr-review-etiquette] and the [Copilot review runbook][copilot-runbook]. +- **The maintainer merges.** The agent drives to green and stops. +- **Fix systemic drift in the hub instead.** Where many repositories share a drift, fix the rule or add a check here and let a re-audit re-flag it, rather than hand-patching each repository for a shared cause. + +**Done means measured, not applied.** Re-run the audit after the merge and commit the report, because a convergence asserted without a report is a convergence nobody can check. + +<!-- Repo --> + +[audit]: ./AUDIT.md +[copilot-runbook]: ./.github/copilot-instructions.md +[divergences]: ./spec/divergences.json +[fidelity-model]: ./spec/fidelity-model.md +[files]: ./spec/files.json +[governance-documentation-style]: ./GOVERNANCE.md#documentation-style-conventions +[governance-hub-hosted-tooling]: ./GOVERNANCE.md#hub-hosted-tooling +[governance-pr-review-etiquette]: ./GOVERNANCE.md#pr-review-etiquette +[host-setup]: ./docs/host-setup.md +[project-types]: ./spec/project-types.json +[scripts]: ./scripts/README.md +[standup]: ./STANDUP.md diff --git a/STANDUP.md b/STANDUP.md index 0f0d1b10..df9957e1 100644 --- a/STANDUP.md +++ b/STANDUP.md @@ -1,253 +1,252 @@ -# STANDUP.md - -How an agent takes a repository from nothing (or a partial state) to **operational** against the fleet ground truth. This is the create-to-conformance procedure, and [`AUDIT.md`][audit] is its read-only verifier and owns the definition of done. Both read the same ground truth ([`registry/repos.json`][repos], the [`spec/`][spec] manifests, [`repo-config/`][repo-config], and the prose authorities [`GOVERNANCE.md`][governance], [`CODESTYLE.md`][codestyle] and [`WORKFLOW.md`][workflow]), so a repo stood up by this file passes the audit by construction. - -Standing up a repo is **applying the manifests until the audit passes**, nothing more invented. If a repo needs a construct no manifest covers, that is a spec gap: raise it ([`AUDIT.md`][audit] section 9), never improvise a per-repo answer. This is the downward-audit model (standard-style repos the hub audits against their declared type), which the fleet uses because managing downstream divergence is too costly. - -```mermaid -flowchart TD - s0["0: verify identity, signing, host_gate"] --> s0a["0A: maintainer hands over repo, App, secrets"] - s0a --> s0b["0B: create main + develop empty, feature branch off develop"] - s0b --> s1["1: classify, write registry/repos.json entry"] - s1 --> s1a["1A: carry the instruction set, before authoring anything"] - s1a --> s1b["1B: capture external source, if this repo replaces one"] - s1b --> s2["2: carry the baseline files"] - s2 --> s3["3: stand up the workflows"] - s3 --> s4["4: apply settings, rulesets, secrets"] - s4 --> s5["5: run AUDIT.md"] - s5 -->|"operational, or deltas tracked with an issue"| done["stood up"] - s5 -->|"a construct no manifest covers"| gap["spec gap: raise it, AUDIT.md section 9"] -``` - -## 0. Verify Commit Identity and Signing, Before the First Commit - -Do this before `git init` or any commit, because the window closes at the first one. A repo whose initial history is unsigned or committed under the wrong identity cannot be cleanly repaired: `Require signed commits` blocks the first `develop -> main` release, re-signing that history is a non-fast-forward the `Block force pushes` rule rejects, and completing it needs the ruleset temporarily disabled plus a maintainer force-push that [`docs/repo-config-carry.md`][repo-config-carry] forbids an agent to perform. Greenfield repos where signing is live before the first commit never hit this. - -**Verify the inherited configuration. Never set it.** The host already carries the correct identity, so a repo-local `user.email` is redundant at best and a wrong identity at worst, and it silently shadows the global it overrides. Read the **`--global`** scope explicitly, and run these before there is a repo: - -```shell -git config --global --get user.email # the GitHub noreply address, per GOVERNANCE.md "Git and Commit Rules" -git config --global --get commit.gpgsign # true -git config --global --get user.signingkey # set -git config --global --get gpg.format # ssh for an SSH key; unset or openpgp for GPG - -# prove signing works with a live scratch commit in a disposable scratch repo, not this -# repo (its own git init is still section 0B, below), and not an agent-liveness probe -# (ssh-add -L, gpg --list-secret-keys): a host that signs straight from a key file with no -# agent running passes cleanly and fails that probe. See -# .agents/skills/git-commit-conventions/SKILL.md "Signing, verified not configured" for why. -# One physical line, not backslash-joined: this file is CRLF (the repo's Markdown default), -# and a `\` continuation stops working the moment a stray `\r` lands after it. -d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( trap 'rm -rf "$d"' 0; email=$(git config --global --get user.email) && git init -q "$d" && git -C "$d" commit --allow-empty -q -m check && out=$(git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>') && echo "$out" && ae=$(git -C "$d" log -1 --format='%ae') && ce=$(git -C "$d" log -1 --format='%ce') && case "$out" in sig=G\ *|sig=U\ *) true ;; *) false ;; esac && case "$email" in *@users.noreply.github.com) true ;; *) false ;; esac && [ "$ae" = "$email" ] && [ "$ce" = "$email" ] ) -``` - -`--global` rather than the effective config, because the effective value depends on where the command runs: inside any existing repository a repo-local override wins, so a bare `git config --get user.email` there reports that repository's identity and hides the host setting this step exists to check. The two scopes together are what make the result sound, since this block proves the host is right and the block below proves nothing shadows it. - -**Verify the host's tools in the same step, since identity is only half of what a standup needs from a machine.** The tools carry version floors, and a host below one does not fail cleanly: it answers `--version`, looks healthy, and produces a wrong answer, which is how both host defects this fleet has hit arrived. - -```shell -python3 scripts/host_gate.py # from a hub checkout, against the fleet floors in spec/host-tools.json -``` - -**No `--repo` here, and that is the one place in these procedures where it is omitted deliberately.** The flag points the gate at a repo's own `host-tools.json` so its floors are layered over the fleet ones, and at this step there is no repo to point it at: the target does not exist yet, since this section runs before the `git init` in section 0B, and the file itself arrives with the baseline in section 2. So this run checks the fleet floors, which is all that is knowable now. - -**Re-run it with `--repo` once section 2 has carried the file**, because a bare run reads no declaration but the one at its own working directory, so any floor the target adds goes unapplied. The gate warns when its working directory sits inside a repo whose overlay it did not read, and no warning can name a target that does not exist yet, so this re-run is the only thing that counts the target's floors: - -```shell -python3 scripts/host_gate.py --repo <path-to-target-checkout> # after section 2, so the repo's own floors count -``` - -A finding at either point is a **host** misconfiguration to fix on the machine or surface to the maintainer, never something to patch per repo, and [`docs/host-setup.md`][host-setup] is the contract it checks. - -The scratch commit exercises the whole signing pipeline rather than one delivery path, since `ssh-add -L` or `gpg --list-secret-keys` only prove an agent holds a key and say nothing about a host that signs straight from a key file with no agent running at all, a live and correctly configured case [git-commit-conventions][git-commit-conventions] documents in "Signing, verified not configured", the same rules [GOVERNANCE.md "Git and Commit Rules"][governance-git-and-commit-rules] points to. Signing is **SSH or GPG**, so this judges the configured format by its actual result (`sig=G`, or `sig=U` for a cryptographically good signature from an unrecognized signer, either a GPG key whose trust is merely undefined or an SSH key missing from the local `allowed_signers` file), never by which delivery path produced it. A missing `--global` value, `sig` not reading `G` or `U`, or either printed email not matching the noreply address is a **host** misconfiguration to surface to the maintainer ([`docs/host-setup.md`][host-setup] is the setup procedure), not something to patch per repo. Patching it locally hides a broken host that then produces wrong identities in every other repo on that machine. - -After `git init` and before the first commit, confirm the repo added no override of its own. This one needs a repository, since `--local` fails outside one. Read it here and run it in section 0B, which places it between the init and the first commit, so nothing here is a prompt to init early: - -```shell -git config --local --get user.email || true # expect no output -``` - -**The finding is a printed value, never the exit code.** An unset key prints nothing and makes `git config --get` exit `1`, so reading that as failure inverts the check, and the tolerant tail turns it into a zero exit in any case, which leaves empty output as the whole of the passing result. The tail is in the snippet above so a copy into a `set -e` script does not abort on the expected case. - -After the first commit, confirm it took with `git log -1 --format='%G? author=%an <%ae> committer=%cn <%ce>'`, so the passing result is `G` plus the expected `noreply` address in **both** identities. Read both rather than the author alone: the rule governs the `author` and the `committer` together, GitHub verifies the signature against the **committer**, and a rebase, amend, or cherry-pick rewrites the committer while leaving the author untouched, which is exactly the case an author-only check passes and should not. `git verify-commit HEAD` is the pass/fail form, exiting non-zero on a bad signature and writing its "Good signature" line to stderr rather than emitting a status letter. Section 0B's block runs this line too, in the position described here, which is the same split as the check above. - -## 0A. Hand Over What Only the Maintainer Can Supply - -**Nothing in this procedure creates the GitHub repository.** Creating one is an outward-facing write that [GOVERNANCE.md "Repository Boundaries and Write Safety"][governance-repository-boundaries-and-write-safety] puts behind explicit per-session permission, so the agent asks for it rather than assuming it exists. Hand this list over before step 1, so it is a checklist at the start rather than a discovery at step 4: - -- **The repository**, with its owner, name, and visibility. -- **The GitHub App installed on it.** An App that is created but not installed does not work, per [`repo-config/README.md`][repo-config-readme]. -- **The App secret values**, in the Actions and Dependabot stores both. -- **Every publish credential and environment the repo's mechanisms declare** in [`spec/secrets.json`][secrets], including any environment a deploy gates on. - -**A repo with no remote is not partially stood up. It is not started.** Steps 0 through 3 complete locally and report progress with no repository in existence, so local progress is not evidence of onboarding progress. [`AUDIT.md`][audit] is the check that would catch it, and it reads a live repo, so the one instrument that detects this condition is unavailable exactly while it holds. - -**Escalate a blocking prerequisite the moment it is found, rather than carrying it.** In a task list a pending task and a blocking prerequisite look identical, and the second quietly becomes the first as work continues around it. Stop at the step that needs the missing input and say which input it is. - -## 0B. Create the Branches, Before the First Standup Commit - -**Create both long-lived branches empty and do the whole standup on a feature branch off `develop`.** An agent that starts committing onto whichever branch `git init` produced is writing the repo's permanent history, and every step below is exploratory work that has no business being permanent. Doing this first means nothing ever has to be cleaned off `main` or `develop`, because the only commit either carries is the empty one it starts from and every change after it arrives by pull request. - -1. **Create `main` carrying nothing.** A git branch cannot exist without a commit, so carrying nothing means exactly one signed empty root commit, and section 0's signing window applies to it like any other. -2. **Create `develop` from `main`**, also carrying nothing, so the two start level. -3. **Create the first feature branch from `develop`** and run every step below on it, through the audit in step 5. -4. **Add the repository on GitHub and apply the configuration while still on that branch**, which is step 4 and needs no branch of its own. -5. **Open a normal pull request into `develop`** when the standup is done, and let it squash like any other change. - -Steps 1 through 3 are the block below, run before the repository exists on GitHub. It carries the procedure's only `git init`, so section 0 is read as its `--global` checks running ahead of this block and its two repository-scoped checks sitting inside it, rather than as an init of its own to run first. - -```shell -git init # The host default may be master, which the rename below corrects. -git config --local --get user.email || true # Section 0's override check, whose passing case is no output. -git commit --allow-empty --message "Initial commit" # The one signed empty root commit. -git log -1 --format='%G? author=%an <%ae> committer=%cn <%ce>' # Section 0's verification of that commit. -git branch -M main # Renames whichever branch git init produced, in a repo holding only it. -git branch develop # From main, so the two start level. -git checkout -b "<feature-branch>" develop # Every step below runs here. -``` - -The rename runs unconditionally rather than behind a test of `init.defaultBranch`, because forcing it is correct whether the host produced `master` or `main` and a conditional is one more thing to get wrong. What makes the force safe is where the block runs, in a repository holding one branch and one commit, so it is a rename with nothing to collide with rather than a general-purpose one. In a repository that already carries a `main` the same line overwrites that branch, which is why this block belongs to a fresh `git init` and never to a repair. The rename is written `-M` rather than `--move --force` because `git branch` gained those long options later than the short one, so the long spelling would add a version floor for nothing. `git switch` and `git init --initial-branch` are newer still, which is why neither appears here. It is worth the care because [`docs/host-setup.md`][host-setup] checks that `git` is present and states no floor of its own. The placeholder is quoted for the reason step 4 gives, that an unquoted `<` is input redirection. Both of section 0's checks sit in the block rather than beside it, placed where that section requires them rather than left to a reader to interpolate, and each is read as section 0 reads it. On the second line the finding is the printed value and never the exit status, so the passing case is empty output. Reading `$?` there answers nothing, because `git config --get` exits non-zero on the unset key that is the wanted result and the tolerant tail then returns zero regardless, which is what keeps a copy of the block inside a `set -e` script from aborting on the expected case. On the fourth the passing result is `G` beside the noreply address in the author and the committer both, which reads the empty root commit as the first commit the signing window covers rather than as an exception to it. Push `main` and `develop` once the repository exists and **before** step 4 applies the rulesets, since [`repo-config/main.json`][repo-config-main] carries a `pull_request` rule, so an applied ruleset blocks the direct push that would otherwise seed the branch. Ordering it this way rather than relying on a bypass is deliberate, because who may bypass a ruleset is a human decision no payload declares, and `repo-config/configure.sh` reads the live list and preserves it rather than asserting one, so an agent cannot know from the payload whether a bypass exists. - -**Committing onto `develop` and squashing afterwards does not work**, because `non_fast_forward` is set on both `develop` payloads and rewriting that history is exactly what the rule rejects. This is not hypothetical, since a repo stood up that way was correctly blocked at the point the history needed rewriting, with the standup already written into the branch it had to be lifted off. - -**The protection is uneven, so on an operational repo this instruction is the only thing holding the line.** A release repo's `repo-config/develop.json` carries a `pull_request` rule that blocks a direct commit outright, while `repo-config/operational/develop.json` carries three rules, `deletion`, `non_fast_forward` and `required_signatures`, and none of them stops one. A conformant operational repo therefore accepts the commit that this step exists to prevent, and reports nothing wrong afterwards. That is a recorded disposition rather than an oversight, `accepted` in the [`docs/fleet-map.md`][fleet-map] register (G7): the allowance is the operational model's foundation, a standup runs on a feature branch either way, and a ruleset tightened for standup alone would leave a window where the live ruleset contradicts the registry's model, so this instruction stays the enforcement. - -**On a public repo the squash is the one chance to leave the exploratory history out.** Standup is where a wrong secret value, a throwaway credential, and a run of noise commits accumulate, and a squashed feature branch publishes the result rather than the route to it. - -## 1. Classify and Catalog - -Resolve the repo's type(s) with the [`AUDIT.md`][audit] section 2 detection rules, then write or repair its [`registry/repos.json`][repos] entry: `status`, `types[]`, `groundTruthBranch`, `hasDevelop`, `publish[]`, `requiredSecrets[]`, `consumerModel`, `releaseTrigger`, `workflowModel` (omit to take the `release` default), `configLayout`, and `driftNotes` that describe what the repo **actually is**. Run [`spec/validate.py`][validate] to confirm it classifies cleanly. The registry is ground truth about reality, not intent, and a `validate.py`-clean entry is still false if it disagrees with the live repo. - -## 1A. Carry the Instruction Set, Before Authoring Anything - -**Stop here until the instruction set is present and read.** The baseline in step 2 is one list, but it holds two kinds of file, and this kind is not a deliverable. `AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md` and `WORKFLOW.md` are **the rules for producing every other file in the repo**, so carrying them late means everything authored beforehand was authored against unknown rules. The cost of that is rework rather than a warning, and it scales with how much got written first. - -This is the same shape as step 0. Signing has to be live before the first commit rather than retrofitted, and governance has to be loaded before the first authored file for the same reason: the window closes quietly, and the repair is expensive out of proportion to the prevention. - -Carry these before writing any repo content of your own: - -- [`AGENTS.md`][agents], [`GOVERNANCE.md`][governance], [`CODESTYLE.md`][codestyle], [`WORKFLOW.md`][workflow] and [`AUDIT.md`][audit], adapted rather than cloned for the ones that describe a repo. -- **`.markdownlint-cli2.jsonc` and `cspell.json`**, which are the mechanical half. A rule nothing checks drifts silently, so a repo that carries the prose authorities without the linter configs has guidance and no gate. Scope a linter's **file set in the workflow** rather than relaxing either config, since `.markdownlint-cli2.jsonc` is carried `verbatim`. - -Then **read** `CODESTYLE.md` and the `GOVERNANCE.md` documentation-style rules, rather than only placing the files. Comment shape, one sentence per line, US spelling and the character rules all govern the code and config you are about to write, and none of them are recoverable cheaply afterwards. - -**A caution about learning house style from the carried files.** Some carried configuration still holds comment blocks that predate the current rules, so read the rule text as the authority and do not infer style from a file's existing formatting. Where a carried file and the rules disagree, the rules win and the file is a backlog item for the hub. - -## 1B. Capture the Source, Before It Changes - -**This step applies only when the repo's content comes from a live external system the repo replaces.** The capture is independent of every other step here and runs as early as the source is reachable, ahead of scaffolding where the source is paid for, rented, or scheduled for shutdown. It is the same window-closes shape as steps 0 and 1A, with a harder edge: a source system is not under version control, so nothing about it can be re-derived once it stops serving. - -Capture the source, verify the capture **against the source**, and hold the verification artifacts (a golden URL list, an export manifest of content hashes) as the before-snapshot, then convert from that rather than from the live system. [`docs/content-import.md`][content-import] holds the three failures that make a capture look complete when it is not: an export that omits externally hosted media, a sitemap that is not the URL contract, and an HTTP fetch that returns a derivative rather than the original. Each reconciles cleanly against the artifact the source hands you, which is why the verification has to read the rendered pages, a live crawl, and content hashes instead. - -## 2. Carry the Baseline Files - -Copy every [`spec/files.json`][files] entry whose `appliesTo` matches the repo's **selector set**, **adapted, not cloned**. The selector set is the repo's `types` plus its `workflowModel`, `releaseTrigger`, and `consumerModel`, so filtering on type alone silently drops the entries a non-type selector carries ([`spec/scope-model.md`][scope-model] defines the four namespaces and how they resolve). The prose files (`CODESTYLE.md`, `README.md`, and the like) describe the repo's own toolchain, so adapt them to reality rather than propagating template specifics verbatim (see the "Adapt before propagating" callout in [`CODESTYLE.md`][codestyle], since a verbatim copy that misdescribes the repo is rejected in review). The baseline covers `WORKFLOW.md`, `version.json`, the two rulesets, `.github/dependabot.yml`, `.editorconfig`, `.gitattributes`, `host-tools.json`, the linter configs, and the per-type files (`.vscode/tasks.json` from the language's snippet, `codecov.yml`, `.dockerignore`, `Docker/README.md`). **Every repo carries `repo-config/main.json`**, and only the `develop` payload varies by workflow model: `repo-config/develop.json` for a release repo, `repo-config/operational/develop.json` for an operational one. - -Carry `AGENTS.md`'s skill-dependency pointer paragraph, the one naming `scripts/skills_install.py` and where the fleet's Skills live, as one more verbatim unit in this same step, not a separate pass. It reads like boilerplate next to the surrounding text a new repo adapts to describe itself, and a repo that carries `AGENTS.md` without it stands up with no path to the fleet's Skills at all. `RESYNC.md` carries the identical instruction for a repo already stood up, so the two procedures agree on what belongs in every copy. - -**`version.json` is a file to carry and a floor to choose.** [`WORKFLOW.md`][workflow] D3.3 makes its `version` field the repo's own major.minor floor, with NBGV appending the git height as the patch, so the number carried in with the file is a claim about a release history the new repo does not have. Set it deliberately, at standup, before the first release: - -- **A new project starts at `1.0`**, or at `0.1` while it is deliberately pre-release and its consumers are told so. -- **A project with releases behind it keeps its established scheme**, adapted to NBGV rather than restarted. The field carries a major.minor floor and NBGV counts the patch from the git height rather than from where the published sequence stopped, so a floor matching the published major.minor emits a patch counted from that floor's first commit, which lands under an existing tag whenever the published patch ran ahead of the height. Raise the minor above the highest published one, which clears the collision and leaves nothing to maintain. `versionHeightOffset` shifts the height instead, at the cost of an offset the repo carries from then on. Either way `nbgv get-version` prints the computed version, and it has to sort above the latest tag before the first release. -- **A repo that ships no package still chooses.** An operational or source-only repo releases a tag and a source archive, which is a published version like any other, so "nothing consumes it" is not a reason to leave the carried number in place. -- **Carry only the fields the repo uses.** `nugetPackageVersion` is packaging configuration for a NuGet publisher, so a repo that publishes no package drops the block rather than carrying a setting nothing reads. `publicReleaseRefSpec` names the repo's own default branch, which D3.2 requires it to agree with. - -**This decision is effectively one-way, which is why it belongs here.** Once a repo publishes against a floor, lowering it regresses the released version order, so a floor that was never chosen is kept rather than corrected. Inherited floors are the observed failure, not a hypothetical one: four operational config repos run on a floor none of them picked and have released against it. - -**`host-tools.json` is carried at the repo's root, and it is not the fleet declaration.** [`spec/host-tools.json`][host-tools] states what every repo's procedures need and is the hub's to change. The carried root file states what this repo needs **beyond** that, so it is where a tool only this repo uses, or a floor only this repo requires, is declared. [`scripts/host_gate.py`][host-gate] layers the root file over the fleet one, tighten-only: an entry may add a tool, raise a floor, or turn an optional tool required, and may not lower a floor or turn a required tool optional, since that retires a fleet check from inside the repo it protects. A rejected relaxation is reported rather than dropped. A repo with nothing to add carries the stub with an empty `tools` list, the same footing as `OPERATIONS.md`, so the declaration is somewhere a reader finds rather than somewhere they must know to look. - -**The carried copy drops the `$schema` pointer, and that is not an oversight to correct.** The schemas are hub-only and no selector carries one, so a relative pointer copied downstream resolves to a path that repo does not have, and a schema-aware editor then reports the file invalid for a reason the repo cannot fix. This is settled fleet practice rather than a new rule: the hub's `spec/secrets.json` carries `./secrets.schema.json` and every downstream copy of that file omits the key. Copy the structure and leave the pointer behind. - -**Repo-specific content has a declared destination, not a judgment call.** The baseline is what a repo *carries*. Anything the repo knows that the fleet does not needs somewhere to live, and improvising a location per repo is what the destinations in [`spec/section-model.md`][section-model] exist to prevent. Four topical docs take it, chosen by what the content **is**: - -- [`CODESTYLE.md`][codestyle]: the repo's language and formatting conventions beyond the carried rules. -- `ARCHITECTURE.md`: how a code repo is built, its module layout, data flow, and design decisions. -- `OPERATIONS.md`: how the repo is run, under the headings `Local Verification`, `Runbooks`, `Backup and Recovery`, `Logs and Debugging`, `Tool Usage`, and `Configuration Layout`. `Local Verification` leads because it is the only pre-merge heading, and it names the part of the repo's contract CI structurally cannot exercise. -- `TODO.md`: the repo's running backlog, per [`spec/readme-structure.md`][readme-structure]. It keeps open work out of the README's section order, where it does not belong and changes on a different cadence from everything around it. - -**`OPERATIONS.md` is required on every repo**, not optional, so it appears in the baseline above with `appliesTo: "*"`. It is presence-checked only, the same footing as `README.md` and `HISTORY.md`, so its content is entirely the repo's own and a repo with little to say still carries the file as a stub, meaning those six headings with no content under them, for which this repo's own `OPERATIONS.md` is the worked example. Do not read the `operational` workflow model into the requirement, because that selector describes where config lives rather than whether the repo has runbooks, and a repo that publishes to a package registry or deploys a site has operational surface under either model. It is the operational analogue of `ARCHITECTURE.md`, and it is where an `AGENTS.md` split puts the repo-specific half, so real runbooks (a deploy procedure, a rollback, a retention policy, a credential rotation) go there rather than into a carried file. It is agent-instruction content, so it takes the inline-link exception the Markdown rules name rather than the reference-style default. `ARCHITECTURE.md` and `TODO.md` stay advisory and are required by no selector, so a repo with nothing to say in one carries no file rather than an empty one. - -Choose the destination while scaffolding rather than after. Repo-specific content left in a carried file is drift, which the audit lists as an undeclared section to reconcile, and reconciling it later means moving prose that downstream readers have already started trusting in the wrong place. - -## 3. Stand Up the Workflows - -Implement the Actions that satisfy [`WORKFLOW.md`][workflow] for the repo's type (its section 6 per-type walkthrough): the source-only subset for a source-only repo, the file-target leaf(s) for a publishing repo, the two-workflow shape for an operational config repo. Reuse [`catalog/snippets/workflows/`][workflows] as the reference implementation, satisfying the contract by outcome rather than byte for byte. - -## 4. Apply Settings, Rulesets, and Secrets - -**Read the remote and the repository before running anything else here**, since this is the first step needing either and every step before it passes without both: - -```shell -git remote get-url origin # expect a URL, not an error -gh repo view "<owner>/<repo>" --json nameWithOwner,visibility -``` - -The placeholder is quoted because an unquoted `<` is input redirection, so the line fails on paste against a file rather than against the repository. - -Three conditions fail here, and the two commands together are what separate them: - -- **No `origin`.** The checkout has nowhere to push even where the repository exists, and it is the state a local-only standup reaches with every step reporting success. -- **No repository.** It surfaces as a resolution error against whatever `configure.sh` calls first, which reads as a permissions or naming problem rather than as the missing prerequisite it is. -- **The two disagree.** Neither command checks this, so compare the `origin` URL against `nameWithOwner` and confirm they name the same repository. - -Each is step 0A's escalation rather than something to work around. - -Run `repo-config/configure.sh apply owner/repo release|operational` from a hub checkout, naming the repo being stood up and its model, to apply the fleet settings, the Dependabot security features, and the two rulesets idempotently (import the JSON, never hand-build it, per [`docs/repo-config-carry.md`][repo-config-carry]), then `repo-config/configure.sh check owner/repo release|operational` to validate the repo and exit non-zero on any drift. The script is hub-hosted rather than carried, so the repo being stood up holds no copy of it and never needs one, and naming the target is what keeps the write off the checkout the command runs in. Pass the model explicitly here rather than relying on the lookup. Run from a hub checkout the registry is present, so a repo not yet registered resolves through `defaults.workflowModel` to `release` and applies the wrong `develop` ruleset to an operational repo, and a repo being stood up is exactly the one the registry has not got yet. Reconcile its registry entry in step 6 either way. Configure every required secret per [`spec/secrets.json`][secrets] (the registry `requiredSecrets[]` list plus the implicit baseline) in the right store(s), meaning Actions plus Dependabot where the mechanism needs it, and confirm no forbidden secret is present. The required check binds by name (`Check pull request workflow status job`) and turns green only after the PR workflow has run once, which is why this step follows step 3 rather than preceding it. A ruleset requiring a name no run has ever reported leaves the first pull request waiting on a status nothing produces, and on an operational repo the `develop -> main` promotion is a pull request too, so the same wait applies there. - -## 5. Verify: Run the Audit - -Run [`AUDIT.md`][audit] end to end. The repo is stood up only when it is **operational** (every applicable check passes) or its residual deltas are tracked in `reports/<repo>/audit.md` plus an issue. Converge any drift through a Copilot-reviewed target PR ([`AUDIT.md`][audit] section 10), and the maintainer merges. A repo left partially set up and unrecorded is the exact failure this procedure exists to prevent. - -## Onboarding a New Repo Type - -When a repo matches no existing type, the work is onboarding a **type**, not just a repo: - -1. Add the type to [`spec/project-types.json`][project-types] (`detect[]`, plus `checks` with verdict tiers and intent refs) and any per-type files to [`spec/files.json`][files], then add its publish mechanism to [`spec/secrets.json`][secrets] if new. Add the type's token to [`spec/scope-model.md`][scope-model] and the type itself to [`spec/type-model.md`][type-model] in the same change, which that file's own rule requires. A type publishing to a **new destination** also needs the target added to the closed `target` enum in [`registry/repos.schema.json`][repos-schema] and mapped in `targetMechanisms`, or the first repo declaring it fails `spec/validate.py` with an unknown-target error. -2. Add the reference workflow leaf to [`catalog/snippets/workflows/`][workflows] and document the type's [`WORKFLOW.md`][workflow] walkthrough. A leaf must not be named `build-*-task.yml` unless the type really is a build target, since `source-only.detect` is literally "no `build-*-task.yml`" and the name alone would make that declaration false for any repo carrying both. -3. Add the type to the [conformance matrix][matrix] and run the cold-start self-test until a context-free agent stands it up to operational. - -## Self-Test: Cold-Start Conformance - -The onboarding docs are sufficient only if a **context-free agent stands up each supported repo shape from them alone**, a shape being the project type(s) plus the workflow model (`operational` is a `workflowModel` overlay, not a `spec/project-types.json` type). Run this whenever the onboarding docs or manifests change, and periodically as a fleet health check: - -- For each shape in the [conformance matrix][matrix], task a fresh agent (no prior context) with "Using only this repo's docs, stand up a `<shape>` repo," pointing it at this file. -- Run [`AUDIT.md`][audit] against the result. Record pass or fail, and the first doc gap that tripped the agent, in the [conformance matrix][matrix]. -- Iterate the **docs and tooling** (not the agent's memory) until every supported shape stands up cold to operational. A shape that cannot be stood up cold is a documentation defect, tracked like any other. - -The same [`AUDIT.md`][audit] run is the on-demand audit for any known repo, and its report lists deviations and repo-specific deltas. The self-test and the fleet audit are one procedure, pointed at a new repo or an existing one. - -<!-- Workflow --> - -[workflows]: ./catalog/snippets/workflows/ - -<!-- Repo --> - -[agents]: ./AGENTS.md -[audit]: ./AUDIT.md -[codestyle]: ./CODESTYLE.md -[content-import]: ./docs/content-import.md -[files]: ./spec/files.json -[fleet-map]: ./docs/fleet-map.md -[git-commit-conventions]: ./.agents/skills/git-commit-conventions/SKILL.md -[governance]: ./GOVERNANCE.md -[governance-git-and-commit-rules]: ./GOVERNANCE.md#git-and-commit-rules -[governance-repository-boundaries-and-write-safety]: ./GOVERNANCE.md#repository-boundaries-and-write-safety -[host-gate]: ./scripts/host_gate.py -[host-setup]: ./docs/host-setup.md -[host-tools]: ./spec/host-tools.json -[matrix]: ./reports/conformance-matrix.md -[project-types]: ./spec/project-types.json -[readme-structure]: ./spec/readme-structure.md -[repo-config]: ./repo-config/ -[repo-config-carry]: ./docs/repo-config-carry.md -[repo-config-main]: ./repo-config/main.json -[repo-config-readme]: ./repo-config/README.md -[repos]: ./registry/repos.json -[repos-schema]: ./registry/repos.schema.json -[scope-model]: ./spec/scope-model.md -[secrets]: ./spec/secrets.json -[section-model]: ./spec/section-model.md -[spec]: ./spec/ -[type-model]: ./spec/type-model.md -[validate]: ./spec/validate.py -[workflow]: ./WORKFLOW.md +# STANDUP.md + +How an agent takes a repository from nothing (or a partial state) to **operational** against the fleet ground truth. This is the create-to-conformance procedure, and [`AUDIT.md`][audit] is its read-only verifier and owns the definition of done. Both read the same ground truth ([`registry/repos.json`][repos], the [`spec/`][spec] manifests, [`repo-config/`][repo-config], and the prose authorities [`GOVERNANCE.md`][governance], [`CODESTYLE.md`][codestyle] and [`WORKFLOW.md`][workflow]), so a repo stood up by this file passes the audit by construction. + +Standing up a repo is **applying the manifests until the audit passes**, nothing more invented. If a repo needs a construct no manifest covers, that is a spec gap: raise it ([`AUDIT.md`][audit] section 9), never improvise a per-repo answer. This is the downward-audit model (standard-style repos the hub audits against their declared type), which the fleet uses because managing downstream divergence is too costly. + +```mermaid +flowchart TD + s0["0: verify identity, signing, host_gate"] --> s0a["0A: maintainer hands over repo, App, secrets"] + s0a --> s0b["0B: create main + develop empty, feature branch off develop"] + s0b --> s1["1: classify, write registry/repos.json entry"] + s1 --> s1a["1A: carry the instruction set, before authoring anything"] + s1a --> s1b["1B: capture external source, if this repo replaces one"] + s1b --> s2["2: carry the baseline files"] + s2 --> s3["3: stand up the workflows"] + s3 --> s4["4: apply settings, rulesets, secrets"] + s4 --> s5["5: run AUDIT.md"] + s5 -->|"operational, or deltas tracked with an issue"| done["stood up"] + s5 -->|"a construct no manifest covers"| gap["spec gap: raise it, AUDIT.md section 9"] +``` + +## 0. Verify Commit Identity and Signing, Before the First Commit + +Do this before `git init` or any commit, because the window closes at the first one. A repo whose initial history is unsigned or committed under the wrong identity cannot be cleanly repaired: `Require signed commits` blocks the first `develop -> main` release, re-signing that history is a non-fast-forward the `Block force pushes` rule rejects, and completing it needs the ruleset temporarily disabled plus a maintainer force-push that [`docs/repo-config-carry.md`][repo-config-carry] forbids an agent to perform. Greenfield repos where signing is live before the first commit never hit this. + +**Verify the inherited configuration. Never set it.** The host already carries the correct identity, so a repo-local `user.email` is redundant at best and a wrong identity at worst, and it silently shadows the global it overrides. Read the **`--global`** scope explicitly, and run these before there is a repo: + +```shell +git config --global --get user.email # the GitHub noreply address, per GOVERNANCE.md "Git and Commit Rules" +git config --global --get commit.gpgsign # true +git config --global --get user.signingkey # set +git config --global --get gpg.format # ssh for an SSH key; unset or openpgp for GPG + +# prove signing works with a live scratch commit in a disposable scratch repo, not this +# repo (its own git init is still section 0B, below), and not an agent-liveness probe +# (ssh-add -L, gpg --list-secret-keys): a host that signs straight from a key file with no +# agent running passes cleanly and fails that probe. See +# .agents/skills/git-commit-conventions/SKILL.md "Signing, verified not configured" for why. +# One physical line, not backslash-joined, so the whole probe copy-pastes cleanly into a shell. +d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( trap 'rm -rf "$d"' 0; email=$(git config --global --get user.email) && git init -q "$d" && git -C "$d" commit --allow-empty -q -m check && out=$(git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>') && echo "$out" && ae=$(git -C "$d" log -1 --format='%ae') && ce=$(git -C "$d" log -1 --format='%ce') && case "$out" in sig=G\ *|sig=U\ *) true ;; *) false ;; esac && case "$email" in *@users.noreply.github.com) true ;; *) false ;; esac && [ "$ae" = "$email" ] && [ "$ce" = "$email" ] ) +``` + +`--global` rather than the effective config, because the effective value depends on where the command runs: inside any existing repository a repo-local override wins, so a bare `git config --get user.email` there reports that repository's identity and hides the host setting this step exists to check. The two scopes together are what make the result sound, since this block proves the host is right and the block below proves nothing shadows it. + +**Verify the host's tools in the same step, since identity is only half of what a standup needs from a machine.** The tools carry version floors, and a host below one does not fail cleanly: it answers `--version`, looks healthy, and produces a wrong answer, which is how both host defects this fleet has hit arrived. + +```shell +python3 scripts/host_gate.py # from a hub checkout, against the fleet floors in spec/host-tools.json +``` + +**No `--repo` here, and that is the one place in these procedures where it is omitted deliberately.** The flag points the gate at a repo's own `host-tools.json` so its floors are layered over the fleet ones, and at this step there is no repo to point it at: the target does not exist yet, since this section runs before the `git init` in section 0B, and the file itself arrives with the baseline in section 2. So this run checks the fleet floors, which is all that is knowable now. + +**Re-run it with `--repo` once section 2 has carried the file**, because a bare run reads no declaration but the one at its own working directory, so any floor the target adds goes unapplied. The gate warns when its working directory sits inside a repo whose overlay it did not read, and no warning can name a target that does not exist yet, so this re-run is the only thing that counts the target's floors: + +```shell +python3 scripts/host_gate.py --repo <path-to-target-checkout> # after section 2, so the repo's own floors count +``` + +A finding at either point is a **host** misconfiguration to fix on the machine or surface to the maintainer, never something to patch per repo, and [`docs/host-setup.md`][host-setup] is the contract it checks. + +The scratch commit exercises the whole signing pipeline rather than one delivery path, since `ssh-add -L` or `gpg --list-secret-keys` only prove an agent holds a key and say nothing about a host that signs straight from a key file with no agent running at all, a live and correctly configured case [git-commit-conventions][git-commit-conventions] documents in "Signing, verified not configured", the same rules [GOVERNANCE.md "Git and Commit Rules"][governance-git-and-commit-rules] points to. Signing is **SSH or GPG**, so this judges the configured format by its actual result (`sig=G`, or `sig=U` for a cryptographically good signature from an unrecognized signer, either a GPG key whose trust is merely undefined or an SSH key missing from the local `allowed_signers` file), never by which delivery path produced it. A missing `--global` value, `sig` not reading `G` or `U`, or either printed email not matching the noreply address is a **host** misconfiguration to surface to the maintainer ([`docs/host-setup.md`][host-setup] is the setup procedure), not something to patch per repo. Patching it locally hides a broken host that then produces wrong identities in every other repo on that machine. + +After `git init` and before the first commit, confirm the repo added no override of its own. This one needs a repository, since `--local` fails outside one. Read it here and run it in section 0B, which places it between the init and the first commit, so nothing here is a prompt to init early: + +```shell +git config --local --get user.email || true # expect no output +``` + +**The finding is a printed value, never the exit code.** An unset key prints nothing and makes `git config --get` exit `1`, so reading that as failure inverts the check, and the tolerant tail turns it into a zero exit in any case, which leaves empty output as the whole of the passing result. The tail is in the snippet above so a copy into a `set -e` script does not abort on the expected case. + +After the first commit, confirm it took with `git log -1 --format='%G? author=%an <%ae> committer=%cn <%ce>'`, so the passing result is `G` plus the expected `noreply` address in **both** identities. Read both rather than the author alone: the rule governs the `author` and the `committer` together, GitHub verifies the signature against the **committer**, and a rebase, amend, or cherry-pick rewrites the committer while leaving the author untouched, which is exactly the case an author-only check passes and should not. `git verify-commit HEAD` is the pass/fail form, exiting non-zero on a bad signature and writing its "Good signature" line to stderr rather than emitting a status letter. Section 0B's block runs this line too, in the position described here, which is the same split as the check above. + +## 0A. Hand Over What Only the Maintainer Can Supply + +**Nothing in this procedure creates the GitHub repository.** Creating one is an outward-facing write that [GOVERNANCE.md "Repository Boundaries and Write Safety"][governance-repository-boundaries-and-write-safety] puts behind explicit per-session permission, so the agent asks for it rather than assuming it exists. Hand this list over before step 1, so it is a checklist at the start rather than a discovery at step 4: + +- **The repository**, with its owner, name, and visibility. +- **The GitHub App installed on it.** An App that is created but not installed does not work, per [`repo-config/README.md`][repo-config-readme]. +- **The App secret values**, in the Actions and Dependabot stores both. +- **Every publish credential and environment the repo's mechanisms declare** in [`spec/secrets.json`][secrets], including any environment a deploy gates on. + +**A repo with no remote is not partially stood up. It is not started.** Steps 0 through 3 complete locally and report progress with no repository in existence, so local progress is not evidence of onboarding progress. [`AUDIT.md`][audit] is the check that would catch it, and it reads a live repo, so the one instrument that detects this condition is unavailable exactly while it holds. + +**Escalate a blocking prerequisite the moment it is found, rather than carrying it.** In a task list a pending task and a blocking prerequisite look identical, and the second quietly becomes the first as work continues around it. Stop at the step that needs the missing input and say which input it is. + +## 0B. Create the Branches, Before the First Standup Commit + +**Create both long-lived branches empty and do the whole standup on a feature branch off `develop`.** An agent that starts committing onto whichever branch `git init` produced is writing the repo's permanent history, and every step below is exploratory work that has no business being permanent. Doing this first means nothing ever has to be cleaned off `main` or `develop`, because the only commit either carries is the empty one it starts from and every change after it arrives by pull request. + +1. **Create `main` carrying nothing.** A git branch cannot exist without a commit, so carrying nothing means exactly one signed empty root commit, and section 0's signing window applies to it like any other. +2. **Create `develop` from `main`**, also carrying nothing, so the two start level. +3. **Create the first feature branch from `develop`** and run every step below on it, through the audit in step 5. +4. **Add the repository on GitHub and apply the configuration while still on that branch**, which is step 4 and needs no branch of its own. +5. **Open a normal pull request into `develop`** when the standup is done, and let it squash like any other change. + +Steps 1 through 3 are the block below, run before the repository exists on GitHub. It carries the procedure's only `git init`, so section 0 is read as its `--global` checks running ahead of this block and its two repository-scoped checks sitting inside it, rather than as an init of its own to run first. + +```shell +git init # The host default may be master, which the rename below corrects. +git config --local --get user.email || true # Section 0's override check, whose passing case is no output. +git commit --allow-empty --message "Initial commit" # The one signed empty root commit. +git log -1 --format='%G? author=%an <%ae> committer=%cn <%ce>' # Section 0's verification of that commit. +git branch -M main # Renames whichever branch git init produced, in a repo holding only it. +git branch develop # From main, so the two start level. +git checkout -b "<feature-branch>" develop # Every step below runs here. +``` + +The rename runs unconditionally rather than behind a test of `init.defaultBranch`, because forcing it is correct whether the host produced `master` or `main` and a conditional is one more thing to get wrong. What makes the force safe is where the block runs, in a repository holding one branch and one commit, so it is a rename with nothing to collide with rather than a general-purpose one. In a repository that already carries a `main` the same line overwrites that branch, which is why this block belongs to a fresh `git init` and never to a repair. The rename is written `-M` rather than `--move --force` because `git branch` gained those long options later than the short one, so the long spelling would add a version floor for nothing. `git switch` and `git init --initial-branch` are newer still, which is why neither appears here. It is worth the care because [`docs/host-setup.md`][host-setup] checks that `git` is present and states no floor of its own. The placeholder is quoted for the reason step 4 gives, that an unquoted `<` is input redirection. Both of section 0's checks sit in the block rather than beside it, placed where that section requires them rather than left to a reader to interpolate, and each is read as section 0 reads it. On the second line the finding is the printed value and never the exit status, so the passing case is empty output. Reading `$?` there answers nothing, because `git config --get` exits non-zero on the unset key that is the wanted result and the tolerant tail then returns zero regardless, which is what keeps a copy of the block inside a `set -e` script from aborting on the expected case. On the fourth the passing result is `G` beside the noreply address in the author and the committer both, which reads the empty root commit as the first commit the signing window covers rather than as an exception to it. Push `main` and `develop` once the repository exists and **before** step 4 applies the rulesets, since [`repo-config/main.json`][repo-config-main] carries a `pull_request` rule, so an applied ruleset blocks the direct push that would otherwise seed the branch. Ordering it this way rather than relying on a bypass is deliberate, because who may bypass a ruleset is a human decision no payload declares, and `repo-config/configure.sh` reads the live list and preserves it rather than asserting one, so an agent cannot know from the payload whether a bypass exists. + +**Committing onto `develop` and squashing afterwards does not work**, because `non_fast_forward` is set on both `develop` payloads and rewriting that history is exactly what the rule rejects. This is not hypothetical, since a repo stood up that way was correctly blocked at the point the history needed rewriting, with the standup already written into the branch it had to be lifted off. + +**The protection is uneven, so on an operational repo this instruction is the only thing holding the line.** A release repo's `repo-config/develop.json` carries a `pull_request` rule that blocks a direct commit outright, while `repo-config/operational/develop.json` carries three rules, `deletion`, `non_fast_forward` and `required_signatures`, and none of them stops one. A conformant operational repo therefore accepts the commit that this step exists to prevent, and reports nothing wrong afterwards. That is a recorded disposition rather than an oversight, `accepted` in the [`docs/fleet-map.md`][fleet-map] register (G7): the allowance is the operational model's foundation, a standup runs on a feature branch either way, and a ruleset tightened for standup alone would leave a window where the live ruleset contradicts the registry's model, so this instruction stays the enforcement. + +**On a public repo the squash is the one chance to leave the exploratory history out.** Standup is where a wrong secret value, a throwaway credential, and a run of noise commits accumulate, and a squashed feature branch publishes the result rather than the route to it. + +## 1. Classify and Catalog + +Resolve the repo's type(s) with the [`AUDIT.md`][audit] section 2 detection rules, then write or repair its [`registry/repos.json`][repos] entry: `status`, `types[]`, `groundTruthBranch`, `hasDevelop`, `publish[]`, `requiredSecrets[]`, `consumerModel`, `releaseTrigger`, `workflowModel` (omit to take the `release` default), `configLayout`, and `driftNotes` that describe what the repo **actually is**. Run [`spec/validate.py`][validate] to confirm it classifies cleanly. The registry is ground truth about reality, not intent, and a `validate.py`-clean entry is still false if it disagrees with the live repo. + +## 1A. Carry the Instruction Set, Before Authoring Anything + +**Stop here until the instruction set is present and read.** The baseline in step 2 is one list, but it holds two kinds of file, and this kind is not a deliverable. `AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md` and `WORKFLOW.md` are **the rules for producing every other file in the repo**, so carrying them late means everything authored beforehand was authored against unknown rules. The cost of that is rework rather than a warning, and it scales with how much got written first. + +This is the same shape as step 0. Signing has to be live before the first commit rather than retrofitted, and governance has to be loaded before the first authored file for the same reason: the window closes quietly, and the repair is expensive out of proportion to the prevention. + +Carry these before writing any repo content of your own: + +- [`AGENTS.md`][agents], [`GOVERNANCE.md`][governance], [`CODESTYLE.md`][codestyle], [`WORKFLOW.md`][workflow] and [`AUDIT.md`][audit], adapted rather than cloned for the ones that describe a repo. +- **`.markdownlint-cli2.jsonc` and `cspell.json`**, which are the mechanical half. A rule nothing checks drifts silently, so a repo that carries the prose authorities without the linter configs has guidance and no gate. Scope a linter's **file set in the workflow** rather than relaxing either config, since `.markdownlint-cli2.jsonc` is carried `verbatim`. + +Then **read** `CODESTYLE.md` and the `GOVERNANCE.md` documentation-style rules, rather than only placing the files. Comment shape, one sentence per line, US spelling and the character rules all govern the code and config you are about to write, and none of them are recoverable cheaply afterwards. + +**A caution about learning house style from the carried files.** Some carried configuration still holds comment blocks that predate the current rules, so read the rule text as the authority and do not infer style from a file's existing formatting. Where a carried file and the rules disagree, the rules win and the file is a backlog item for the hub. + +## 1B. Capture the Source, Before It Changes + +**This step applies only when the repo's content comes from a live external system the repo replaces.** The capture is independent of every other step here and runs as early as the source is reachable, ahead of scaffolding where the source is paid for, rented, or scheduled for shutdown. It is the same window-closes shape as steps 0 and 1A, with a harder edge: a source system is not under version control, so nothing about it can be re-derived once it stops serving. + +Capture the source, verify the capture **against the source**, and hold the verification artifacts (a golden URL list, an export manifest of content hashes) as the before-snapshot, then convert from that rather than from the live system. [`docs/content-import.md`][content-import] holds the three failures that make a capture look complete when it is not: an export that omits externally hosted media, a sitemap that is not the URL contract, and an HTTP fetch that returns a derivative rather than the original. Each reconciles cleanly against the artifact the source hands you, which is why the verification has to read the rendered pages, a live crawl, and content hashes instead. + +## 2. Carry the Baseline Files + +Copy every [`spec/files.json`][files] entry whose `appliesTo` matches the repo's **selector set**, **adapted, not cloned**. The selector set is the repo's `types` plus its `workflowModel`, `releaseTrigger`, and `consumerModel`, so filtering on type alone silently drops the entries a non-type selector carries ([`spec/scope-model.md`][scope-model] defines the four namespaces and how they resolve). The prose files (`CODESTYLE.md`, `README.md`, and the like) describe the repo's own toolchain, so adapt them to reality rather than propagating template specifics verbatim (see the "Adapt before propagating" callout in [`CODESTYLE.md`][codestyle], since a verbatim copy that misdescribes the repo is rejected in review). The baseline covers `WORKFLOW.md`, `version.json`, the two rulesets, `.github/dependabot.yml`, `.editorconfig`, `.gitattributes`, `host-tools.json`, the linter configs, and the per-type files (`.vscode/tasks.json` from the language's snippet, `codecov.yml`, `.dockerignore`, `Docker/README.md`). **Every repo carries `repo-config/main.json`**, and only the `develop` payload varies by workflow model: `repo-config/develop.json` for a release repo, `repo-config/operational/develop.json` for an operational one. + +Carry `AGENTS.md`'s skill-dependency pointer paragraph, the one naming `scripts/skills_install.py` and where the fleet's Skills live, as one more verbatim unit in this same step, not a separate pass. It reads like boilerplate next to the surrounding text a new repo adapts to describe itself, and a repo that carries `AGENTS.md` without it stands up with no path to the fleet's Skills at all. `RESYNC.md` carries the identical instruction for a repo already stood up, so the two procedures agree on what belongs in every copy. + +**`version.json` is a file to carry and a floor to choose.** [`WORKFLOW.md`][workflow] D3.3 makes its `version` field the repo's own major.minor floor, with NBGV appending the git height as the patch, so the number carried in with the file is a claim about a release history the new repo does not have. Set it deliberately, at standup, before the first release: + +- **A new project starts at `1.0`**, or at `0.1` while it is deliberately pre-release and its consumers are told so. +- **A project with releases behind it keeps its established scheme**, adapted to NBGV rather than restarted. The field carries a major.minor floor and NBGV counts the patch from the git height rather than from where the published sequence stopped, so a floor matching the published major.minor emits a patch counted from that floor's first commit, which lands under an existing tag whenever the published patch ran ahead of the height. Raise the minor above the highest published one, which clears the collision and leaves nothing to maintain. `versionHeightOffset` shifts the height instead, at the cost of an offset the repo carries from then on. Either way `nbgv get-version` prints the computed version, and it has to sort above the latest tag before the first release. +- **A repo that ships no package still chooses.** An operational or source-only repo releases a tag and a source archive, which is a published version like any other, so "nothing consumes it" is not a reason to leave the carried number in place. +- **Carry only the fields the repo uses.** `nugetPackageVersion` is packaging configuration for a NuGet publisher, so a repo that publishes no package drops the block rather than carrying a setting nothing reads. `publicReleaseRefSpec` names the repo's own default branch, which D3.2 requires it to agree with. + +**This decision is effectively one-way, which is why it belongs here.** Once a repo publishes against a floor, lowering it regresses the released version order, so a floor that was never chosen is kept rather than corrected. Inherited floors are the observed failure, not a hypothetical one: four operational config repos run on a floor none of them picked and have released against it. + +**`host-tools.json` is carried at the repo's root, and it is not the fleet declaration.** [`spec/host-tools.json`][host-tools] states what every repo's procedures need and is the hub's to change. The carried root file states what this repo needs **beyond** that, so it is where a tool only this repo uses, or a floor only this repo requires, is declared. [`scripts/host_gate.py`][host-gate] layers the root file over the fleet one, tighten-only: an entry may add a tool, raise a floor, or turn an optional tool required, and may not lower a floor or turn a required tool optional, since that retires a fleet check from inside the repo it protects. A rejected relaxation is reported rather than dropped. A repo with nothing to add carries the stub with an empty `tools` list, the same footing as `OPERATIONS.md`, so the declaration is somewhere a reader finds rather than somewhere they must know to look. + +**The carried copy drops the `$schema` pointer, and that is not an oversight to correct.** The schemas are hub-only and no selector carries one, so a relative pointer copied downstream resolves to a path that repo does not have, and a schema-aware editor then reports the file invalid for a reason the repo cannot fix. This is settled fleet practice rather than a new rule: the hub's `spec/secrets.json` carries `./secrets.schema.json` and every downstream copy of that file omits the key. Copy the structure and leave the pointer behind. + +**Repo-specific content has a declared destination, not a judgment call.** The baseline is what a repo *carries*. Anything the repo knows that the fleet does not needs somewhere to live, and improvising a location per repo is what the destinations in [`spec/section-model.md`][section-model] exist to prevent. Four topical docs take it, chosen by what the content **is**: + +- [`CODESTYLE.md`][codestyle]: the repo's language and formatting conventions beyond the carried rules. +- `ARCHITECTURE.md`: how a code repo is built, its module layout, data flow, and design decisions. +- `OPERATIONS.md`: how the repo is run, under the headings `Local Verification`, `Runbooks`, `Backup and Recovery`, `Logs and Debugging`, `Tool Usage`, and `Configuration Layout`. `Local Verification` leads because it is the only pre-merge heading, and it names the part of the repo's contract CI structurally cannot exercise. +- `TODO.md`: the repo's running backlog, per [`spec/readme-structure.md`][readme-structure]. It keeps open work out of the README's section order, where it does not belong and changes on a different cadence from everything around it. + +**`OPERATIONS.md` is required on every repo**, not optional, so it appears in the baseline above with `appliesTo: "*"`. It is presence-checked only, the same footing as `README.md` and `HISTORY.md`, so its content is entirely the repo's own and a repo with little to say still carries the file as a stub, meaning those six headings with no content under them, for which this repo's own `OPERATIONS.md` is the worked example. Do not read the `operational` workflow model into the requirement, because that selector describes where config lives rather than whether the repo has runbooks, and a repo that publishes to a package registry or deploys a site has operational surface under either model. It is the operational analogue of `ARCHITECTURE.md`, and it is where an `AGENTS.md` split puts the repo-specific half, so real runbooks (a deploy procedure, a rollback, a retention policy, a credential rotation) go there rather than into a carried file. It is agent-instruction content, so it takes the inline-link exception the Markdown rules name rather than the reference-style default. `ARCHITECTURE.md` and `TODO.md` stay advisory and are required by no selector, so a repo with nothing to say in one carries no file rather than an empty one. + +Choose the destination while scaffolding rather than after. Repo-specific content left in a carried file is drift, which the audit lists as an undeclared section to reconcile, and reconciling it later means moving prose that downstream readers have already started trusting in the wrong place. + +## 3. Stand Up the Workflows + +Implement the Actions that satisfy [`WORKFLOW.md`][workflow] for the repo's type (its section 6 per-type walkthrough): the source-only subset for a source-only repo, the file-target leaf(s) for a publishing repo, the two-workflow shape for an operational config repo. Reuse [`catalog/snippets/workflows/`][workflows] as the reference implementation, satisfying the contract by outcome rather than byte for byte. + +## 4. Apply Settings, Rulesets, and Secrets + +**Read the remote and the repository before running anything else here**, since this is the first step needing either and every step before it passes without both: + +```shell +git remote get-url origin # expect a URL, not an error +gh repo view "<owner>/<repo>" --json nameWithOwner,visibility +``` + +The placeholder is quoted because an unquoted `<` is input redirection, so the line fails on paste against a file rather than against the repository. + +Three conditions fail here, and the two commands together are what separate them: + +- **No `origin`.** The checkout has nowhere to push even where the repository exists, and it is the state a local-only standup reaches with every step reporting success. +- **No repository.** It surfaces as a resolution error against whatever `configure.sh` calls first, which reads as a permissions or naming problem rather than as the missing prerequisite it is. +- **The two disagree.** Neither command checks this, so compare the `origin` URL against `nameWithOwner` and confirm they name the same repository. + +Each is step 0A's escalation rather than something to work around. + +Run `repo-config/configure.sh apply owner/repo release|operational` from a hub checkout, naming the repo being stood up and its model, to apply the fleet settings, the Dependabot security features, and the two rulesets idempotently (import the JSON, never hand-build it, per [`docs/repo-config-carry.md`][repo-config-carry]), then `repo-config/configure.sh check owner/repo release|operational` to validate the repo and exit non-zero on any drift. The script is hub-hosted rather than carried, so the repo being stood up holds no copy of it and never needs one, and naming the target is what keeps the write off the checkout the command runs in. Pass the model explicitly here rather than relying on the lookup. Run from a hub checkout the registry is present, so a repo not yet registered resolves through `defaults.workflowModel` to `release` and applies the wrong `develop` ruleset to an operational repo, and a repo being stood up is exactly the one the registry has not got yet. Reconcile its registry entry in step 6 either way. Configure every required secret per [`spec/secrets.json`][secrets] (the registry `requiredSecrets[]` list plus the implicit baseline) in the right store(s), meaning Actions plus Dependabot where the mechanism needs it, and confirm no forbidden secret is present. The required check binds by name (`Check pull request workflow status job`) and turns green only after the PR workflow has run once, which is why this step follows step 3 rather than preceding it. A ruleset requiring a name no run has ever reported leaves the first pull request waiting on a status nothing produces, and on an operational repo the `develop -> main` promotion is a pull request too, so the same wait applies there. + +## 5. Verify: Run the Audit + +Run [`AUDIT.md`][audit] end to end. The repo is stood up only when it is **operational** (every applicable check passes) or its residual deltas are tracked in `reports/<repo>/audit.md` plus an issue. Converge any drift through a Copilot-reviewed target PR ([`AUDIT.md`][audit] section 10), and the maintainer merges. A repo left partially set up and unrecorded is the exact failure this procedure exists to prevent. + +## Onboarding a New Repo Type + +When a repo matches no existing type, the work is onboarding a **type**, not just a repo: + +1. Add the type to [`spec/project-types.json`][project-types] (`detect[]`, plus `checks` with verdict tiers and intent refs) and any per-type files to [`spec/files.json`][files], then add its publish mechanism to [`spec/secrets.json`][secrets] if new. Add the type's token to [`spec/scope-model.md`][scope-model] and the type itself to [`spec/type-model.md`][type-model] in the same change, which that file's own rule requires. A type publishing to a **new destination** also needs the target added to the closed `target` enum in [`registry/repos.schema.json`][repos-schema] and mapped in `targetMechanisms`, or the first repo declaring it fails `spec/validate.py` with an unknown-target error. +2. Add the reference workflow leaf to [`catalog/snippets/workflows/`][workflows] and document the type's [`WORKFLOW.md`][workflow] walkthrough. A leaf must not be named `build-*-task.yml` unless the type really is a build target, since `source-only.detect` is literally "no `build-*-task.yml`" and the name alone would make that declaration false for any repo carrying both. +3. Add the type to the [conformance matrix][matrix] and run the cold-start self-test until a context-free agent stands it up to operational. + +## Self-Test: Cold-Start Conformance + +The onboarding docs are sufficient only if a **context-free agent stands up each supported repo shape from them alone**, a shape being the project type(s) plus the workflow model (`operational` is a `workflowModel` overlay, not a `spec/project-types.json` type). Run this whenever the onboarding docs or manifests change, and periodically as a fleet health check: + +- For each shape in the [conformance matrix][matrix], task a fresh agent (no prior context) with "Using only this repo's docs, stand up a `<shape>` repo," pointing it at this file. +- Run [`AUDIT.md`][audit] against the result. Record pass or fail, and the first doc gap that tripped the agent, in the [conformance matrix][matrix]. +- Iterate the **docs and tooling** (not the agent's memory) until every supported shape stands up cold to operational. A shape that cannot be stood up cold is a documentation defect, tracked like any other. + +The same [`AUDIT.md`][audit] run is the on-demand audit for any known repo, and its report lists deviations and repo-specific deltas. The self-test and the fleet audit are one procedure, pointed at a new repo or an existing one. + +<!-- Workflow --> + +[workflows]: ./catalog/snippets/workflows/ + +<!-- Repo --> + +[agents]: ./AGENTS.md +[audit]: ./AUDIT.md +[codestyle]: ./CODESTYLE.md +[content-import]: ./docs/content-import.md +[files]: ./spec/files.json +[fleet-map]: ./docs/fleet-map.md +[git-commit-conventions]: ./.agents/skills/git-commit-conventions/SKILL.md +[governance]: ./GOVERNANCE.md +[governance-git-and-commit-rules]: ./GOVERNANCE.md#git-and-commit-rules +[governance-repository-boundaries-and-write-safety]: ./GOVERNANCE.md#repository-boundaries-and-write-safety +[host-gate]: ./scripts/host_gate.py +[host-setup]: ./docs/host-setup.md +[host-tools]: ./spec/host-tools.json +[matrix]: ./reports/conformance-matrix.md +[project-types]: ./spec/project-types.json +[readme-structure]: ./spec/readme-structure.md +[repo-config]: ./repo-config/ +[repo-config-carry]: ./docs/repo-config-carry.md +[repo-config-main]: ./repo-config/main.json +[repo-config-readme]: ./repo-config/README.md +[repos]: ./registry/repos.json +[repos-schema]: ./registry/repos.schema.json +[scope-model]: ./spec/scope-model.md +[secrets]: ./spec/secrets.json +[section-model]: ./spec/section-model.md +[spec]: ./spec/ +[type-model]: ./spec/type-model.md +[validate]: ./spec/validate.py +[workflow]: ./WORKFLOW.md diff --git a/TODO.md b/TODO.md index 76f0f90a..a79ad420 100644 --- a/TODO.md +++ b/TODO.md @@ -1,688 +1,673 @@ -# TODO - -Running backlog for this repo, kept in a committed file so the research survives across environments where agent memory does not. Entries are grouped by the change that ships them, so a `###` heading under "Work Clusters" is one pull request, and selecting work is reading the cluster headings rather than re-deriving the grouping from the entries. What each cluster touches and costs is a field on the cluster, since a cluster confined to one surface and a cluster spanning two are both legitimate and only the second needs saying. - -An entry carries `Blocked by`, `Issue` and `Checked` exactly once each, in that order, and never omits one, because an omitted field reads as unknown rather than as none. `Open` states a decision the session doing the work makes, and `Settled` states a finding that is not re-derived, each carrying a number, a proper name, or a rejected alternative. `Checked` is the freshness anchor, naming the branch, the commit, and the date a claim was last read against the tree, so a claim older than the branch is a claim rather than a finding. - -A cluster's `State` is one of four. `ready` means every open question is answerable by the session doing the work. `blocked` names the cluster it waits on. `decision` needs the maintainer. `measure` means the first action is a count rather than an edit. - -Adoption gaps for the skill-based fleet system are registered in [`docs/fleet-map.md`][fleet-map] rather than here, so the two files do not fork. A new observation about such a gap lands as a register row there, and this file carries only the pointer. - -## How to Select the Next Item - -The steps below are followed in order rather than sampled. - -1. Run `gh issue list --state open` and confirm every number it returns appears somewhere in this file. A number appearing nowhere is an entry that does not exist yet, so write it before selecting anything, because an invisible issue cannot be selected. Nothing mechanical enforces this, which is the honest limit of a hand-maintained file and the reason the step is first. -2. Run `gh pr list --state open` and confirm every pull request it returns carries a **stated active blocker**, written where the pull request itself carries it rather than held in a session that has ended. A blocker is active only while the thing it names is still true, so a review round that has landed, a dependency that has merged, and an outage that has passed each stop being one, and what they leave behind is a forgotten pull request rather than a parked one. The remedy is to finish it, close it, or write the current blocker down, and it happens before selecting new work rather than after, because the cost is not the waiting. A bot pull request is read rather than excluded, since nobody is there to write a blocker on one, so its blocker is whichever gate holds it open and is read off the pull request itself: an unfinished or failing check, a merge state of `BEHIND` because a sibling bot pull request merged first, or auto-merge disabled by a maintainer push, the last two of which [`GOVERNANCE.md`][governance] "Branching Model" documents as expected rather than as faults. One sitting open under none of them is the merge-bot having missed it, which is the finding rather than the exemption. [#591][pr-591] was parked correctly during a GitHub Actions outage and came back three days later twenty commits behind `develop`, conflicting in six regions, and carrying an exit code that had come to mean something else in the meantime. -3. Read the cluster headings and their `State` lines. A cluster is the unit of selection, so pick a cluster rather than an entry, and never carry two clusters in one pull request. -4. Prefer a cluster whose state is `ready`. Select a `decision` cluster only when the maintainer is present to answer its open questions, select a `blocked` cluster only after the cluster it names has shipped, and select a `measure` cluster knowing its deliverable is a number rather than a behavior change. -5. Re-verify every `Checked` line in the chosen cluster against current `develop` before writing anything, by reading the surface the anchor names rather than by re-reading the issue. An issue records the tree as it was on the day it was filed, so a claim in one is a starting point for a check rather than a finding to act on. -6. Rewrite the `Checked` line with the branch, the short commit, and the date whenever a claim is confirmed, whether or not the work ships in the same session. A re-verification that leaves no anchor is a check the next session repeats. -7. Move a claim the tree contradicts out of `Settled` and state what the tree carries instead. Where the tree answers a whole entry, move the entry to "Verified Complete, Awaiting Close" with the commit that answered it, and never delete it silently, since a deleted entry reads as work nobody recorded. -8. Fold a new observation in under one of four dispositions, named on the pull request carrying it: `New entry`, `Amends "<entry title>"`, `Already covered`, or `Already shipped as #N`. A second observation of a surface an entry already reasons about strengthens that entry rather than opening a second one. -9. An amendment adds a `Settled` bullet, shortens `Open`, and refreshes `Checked`. An observation that answers an open question deletes that question rather than annotating it. -10. Delete a cluster heading when its pull request merges, and move anything the pull request did not carry into a new cluster with its own state. - -## Work Clusters - -### Default `.py` to LF Fleet-Wide, Retiring the Per-Path Pin List - -One pull request changing [`GOVERNANCE.md`][governance] "Line Endings", [`.editorconfig`][editorconfig], and [`.gitattributes`][gitattributes] to pin `*.py` LF by extension, replacing the growing list of individually-pinned shebang scripts, plus the one-time renormalization it obliges fleet-wide. - -**State** `decision`. **Touches** `GOVERNANCE.md` "Line Endings" (verbatim, so it re-vendors fleet-wide), `.editorconfig`, `.gitattributes`, and every downstream repo carrying a CRLF `.py` file. **Cost** one hub edit plus a renormalization pass per affected repo. The hub itself needs no renormalization, since every `.py` file it tracks is already LF. - -- **Pin `*.py text eol=lf` by extension and drop the by-path list it replaces.** The by-path list exists because the fleet's default is CRLF for `.py` and only a shebang-executed script needs LF, so each new script has needed its own `.gitattributes` line and its own `.editorconfig` override. It reads 25 entries today, up from the roughly dozen it carried before this session added four more for two new scripts and their tests, which is the divergence outweighing the reason it was chosen. - - **Blocked by** - Nothing. - - **Issue** - None filed. - - **Checked** - `develop` at `7f9caaa` on 2026-08-12, where `.gitattributes` carries 25 LF pins (3 forward-declared) over 19 tracked `.py` files, and every one of those 19 is already LF (none is a vanilla CRLF `.py`), so the hub side of this change is comment-and-pattern-only. - - **Open** - Whether downstream repos with a vanilla (non-shebang) CRLF `.py` file need a coordinated renormalization pass or can pick it up on their own next resync. 5 repos in [`registry/repos.json`][repos] carry the `python` type (`aiopurpleair`, `homeassistant-purpleair`, `Financial-Modeling`, `PlexCleaner`, `ESPHome-Config`) and were not individually checked for CRLF `.py` content as part of writing this entry. - - **Settled** - The default was set to CRLF in #229 for editor compatibility on Windows, not because LF broke anything measured. The reason it is being revisited is that non-VSCode Windows editors were the concern, and the per-path pin list's growth now outweighs that concern's practical weight, per the maintainer. - - **Settled** - The by-path convention was reaffirmed in #503 ("Do not re-add a blanket `*.py text eol=lf`"), but that pull request only reshaped the two files' comment prose and did not re-examine the underlying policy, so it is not a second, independent rejection of this change. - -### Giving the Fleet's Own Pins Something to Resolve Against - -One pull request pointing a hub `uses:` at a hub-owned action, so that the resolvability pass added beside it has a reference under this owner to read. It is separated from that pass because it changes what a workflow runs, where the pass only changes what a gate reports. - -**State** `decision`. **Touches** the hub's own workflows. **Cost** one hub edit, hub-only, and it changes a running workflow so it is not a paper change. - -- **Decide whether the hub consumes its own [`prose-gate`][prose-gate] action the way the fleet does.** Today it calls `prose_lint.py` directly, so every `uses:` in the tree is under another owner. - - **Blocked by** - Nothing, though it is only worth doing on its own merits rather than to give a gate something to read. - - **Issue** - None filed. - - **Checked** - `develop` at `dbd1cdc` on 2026-08-07, where the tree carries 45 pinned `uses:` refs and not one of them names a `ptr727` repository. - - **Open** - Whether the hub gating itself through its own pinned action is desirable at all, given the action reads the rules from hub `develop` on a non-`main` target and the hub already has the script in its own checkout. - - **Settled** - The resolvability pass reports what it covered on every run, so the hub's zero is visible rather than silent, which is why this is a separate decision rather than a defect in that pass. - - **Settled** - The fleet's `ptr727` pins are live in the downstream repos that consume the action, and `repo_gate.py --root <repo>` from a hub checkout reads them there, so the pass is not idle fleet-wide. - -### The Declared Repository Description - -One pull request moving the canonical short description into declared data, so every check and every push reads a field rather than parsing a document, and the About panel gets something that writes it. - -**State** `decision`. **Touches** [`registry/repos.json`][repos] and its schema, [`spec/audit.py`][audit], and `repo-config/configure.sh`. **Cost** one hub edit, and repos adopt the field one at a time. The tagline rule this cluster once carried shipped on 2026-08-08. - -- **Declare the description in [`registry/repos.json`][repos] instead of deriving it by parsing the README.** Every check and every push then reads a field. - - **Blocked by** - Nothing. - - **Issue** - None filed, and the disposition is recorded on [#509][issue-509]. - - **Checked** - `develop` at `3d1a0b1` on 2026-08-06, where neither `registry/repos.json` nor `registry/repos.schema.json` carries a `description` key. - - **Open** - Nothing beyond sequencing, which is that this leads and the README shape follows. - - **Settled** - PhotoCleaner#32 measures the cost of parsing, since a workflow step reading the intro at publish time needs nine guards against headings, block quotes, all four list markers, ordered lists, HTML, tables, code, links and the length cap, and every one of them fails the release rather than the tagline. - - **Settled** - The field makes the README intro a third mirror rather than the source, so the audit compares all three against one declared value and `repo-config/configure.sh` sets the About panel from the same field it already sets every other setting from. - - **Settled** - The 100-character cap stays, since Docker Hub's short description is the tightest surface. - - **Settled** - The field is optional at first so the audit falls back to the README intro while repos adopt it, and it needs a schema entry because `registry/repos.schema.json` sets `additionalProperties: false`. - - **Settled** - The ask on the Docker repos meanwhile is only that the parsing step is not propagated further. - -- **Close the README-to-About hop, which is the only one nothing writes.** The audit reports a drifted About panel, and no tool sets it. - - **Blocked by** - The entry above, since the field is what `repo-config/configure.sh` would set the panel from. - - **Issue** - [#639][issue-639], filed on 2026-08-09 because this entry had been carrying [#577][issue-577], whose body covers only the README tagline and never mentions the About panel, and whose tagline half shipped on 2026-08-08. - - **Checked** - `develop` on 2026-08-08, where `repo-config/configure.sh` sets every other repository setting and carries no `description` handling, and [`catalog/snippets/workflows/publish-docker-readme-task.yml`][workflows] pushes `github.event.repository.description` to Docker Hub. - - **Open** - Nothing beyond sequencing. - - **Settled** - The chain is README, then the About panel by hand, then Docker Hub by CI, so the unautomated hop is the first one and it is the one that drifts. PhotoCleaner is the worked case, where the About panel still matched the README and only the Docker Hub short description had diverged. - - **Settled** - CI keeps reading `repository.description` rather than the README. Pointing it at the README puts a Markdown parser in a publish job, which PhotoCleaner#32 measured at nine guards, every one of which fails the release rather than the tagline. - - **Settled** - The tagline rule itself shipped on 2026-08-08 and is no longer owed here. The extraction rule this entry was once blocked on already existed: [`spec/audit.py`][audit] measured the first line for the About and Docker Hub mirrors all along, and narrowing the `HISTORY.md` mirror to match it was one line, so the sequencing that held the rule behind the registry field was stated more strongly than the code warranted. - -### Content in the Wrong File - -One pull request teaching the audit to see content sitting in a file the section model assigns elsewhere, which is invisible today and reported as a missing file instead. - -**State** `decision`. **Touches** [`spec/audit.py`][audit] and possibly [`spec/files.json`][files]. **Cost** one hub edit, hub-only, and it changes what every repo's next audit reports. - -- **Compare an `intent` file's headings against the destinations the section model assigns.** Collect the level-two headings, subtract the ones the manifest declares for that file, and compare the remainder against the headings other destinations declare. - - **Blocked by** - Nothing. - - **Issue** - [#523][issue-523], which carries the four things to settle. - - **Checked** - `develop` at `1ed0cc8` on 2026-08-03, where the audit checks file presence, declared-section presence, verbatim hashes, and workflow interface conformance, and nothing that reads a heading against a destination. - - **Open** - Whether an undeclared heading is a finding at all, given a repo may legitimately add locally. - - **Open** - Whether the destination mapping becomes declared data rather than prose, and whether it reaches the advisory `ARCHITECTURE.md`. - - **Open** - How many repos are affected, measured before the check is designed rather than after it starts reporting. - - **Settled** - The case that found it is a repo whose `.github/copilot-instructions.md` carried 311 lines under nine headings assigned to `ARCHITECTURE.md` and `OPERATIONS.md`, reported as a missing-file letter while the misplacement that caused it was invisible. - - **Settled** - The similarity-based version is rejected by [`spec/section-model.md`][section-model], and a detector built on it produces findings whose remedy is to delete content. - -### Registry Membership Coverage - -One pull request asking the inverse question the fleet tools never ask, whether a repository that exists has a registry entry, since every tool iterates the registry and an omission at standup is permanent and silent. - -**State** `decision`. **Touches** [`spec/audit.py`][audit], [`registry/repos.json`][repos] and its schema, and [`STANDUP.md`][standup]. **Cost** one hub edit, hub-only. - -- **Report a non-fork repository under the owner that has no registry entry.** The reports read as complete while under-counting today. - - **Blocked by** - Nothing. - - **Issue** - [#550][issue-550], which carries the four repos the comparison found. - - **Checked** - `develop` at `362aec8`, per the issue, and unverified since. - - **Open** - How a deliberate exclusion is recorded, since without one the check becomes a permanent four-line complaint people learn to scroll past, and the candidates are a third `status` value or a separate list carrying a reason per entry. - - **Open** - Where the check runs, since neither `validate.py` in CI nor an owner-initiated audit catches an omission at the moment it is made, which is the standup itself and the moment the fix costs one line. - - **Settled** - The consequence is worse than a gap, because the reports are confidently wrong rather than silent: [reports/divergences.md][divergences-report] counted 19 repos owing `AGENTS.md` "Fleet Bootstrap" when the real number was 20. - - **Settled** - The procedure is not the gap, since [`STANDUP.md`][standup] section 1A already says to write the entry and names every field, and nothing verifies it happened. - - **Settled** - The reason matters more than the mechanism, since an unexplained exclusion is the same silent omission in a different file. - - **Settled** - Private repositories are outside the public listing the issue used, so the true count is a floor rather than a total. - -### Reducing the Carried Surface Further - -One pull request measuring the remaining carried surface against the carry-versus-reach test and moving whatever qualifies, now that the model is settled rather than open. - -**State** `decision`. **Touches** [`AUDIT.md`][audit-doc], [`spec/secrets.json`][secrets], and [`spec/files.json`][files]. **Cost** one hub edit plus a retirement per repo on its next visit. The workflow half of this cluster, replacing copy-pasted workflow content with cross-repo reuse, is measured and answered under "Hub-Hosted Reusable Workflows" below. - -- **Measure carried [`AUDIT.md`][audit-doc] and [`spec/secrets.json`][secrets] against the test.** Each is adapted per repo today and the question is how much of each is genuinely per-repo. - - **Blocked by** - Nothing. - - **Issue** - None filed, and [#305][issue-305] covers the propagation half from the other direction. - - **Checked** - `develop` at `3d1a0b1` on 2026-08-06, where [`spec/files.json`][files] declares both at `intent` and no longer declares `repo-config/configure.sh` at all. - - **Open** - Which of the two moves, if either. - - **Settled** - The test is stated: a repository carries the content it is audited against and the configuration that describes it, and it reaches machinery whose content is identical in every repository. - - **Settled** - `repo-config/configure.sh` is the first file moved across, carrying the ledger's only `retire` disposition and naming six repos, NxWitness, aiopurpleair, homeassistant-purpleair, ESPHome-NonRoot, VSCode-Server-DotNetCore and LanguageTags. - - **Settled** - An unreachable hub means the tool did not run, reported as not run rather than worked around, since a hand-rolled substitute is the duplicated effort the model exists to end. - -### The README Structure Rework - -The spec rework and its audit check shipped. What remains is the per-repo conformance the check now reports, and one section the fleet carries that the model does not name. - -**State** `decision`, on where `## Build Artifacts` belongs, which is the only thing here a hub pull request settles. The four conformance entries above it are not selectable as hub work at all: each lands on a repo's own next visit, in the sense "Fleet Sweeps" below gives that phrase, and they sit here rather than there because the finding counts are what the shipped check measures. **Touches** each repo's `README.md` on its next visit, plus [`spec/readme-structure.md`][readme-structure] and [`spec/readme-sections.json`][readme-sections] if `Build Artifacts` is adopted. **Cost** one edit per repo, driven by the finding rather than by a sweep. - -- **Work off the conformance backlog the `readme-structure` dimension now reports.** Measured across all 22 cataloged repos on 2026-08-08, against the shipped checks: 73 findings, 71 on sections and 2 on shields, plus the 3 retired-badge findings the entry below carries. - - **Blocked by** - Nothing, and no repo is edited by the hub. Each lands on its own next visit. - - **Issue** - None filed. - - **Checked** - Every repo's default branch on 2026-08-08, with the hub read at its own `develop`. - - **Settled** - The shape of the work: 17 repos owe `3rd Party Tools`, 10 owe the `Overview` rename, 9 owe a Table of Contents, 7 owe a License section, and 5 public repos owe `Questions or Issues`. - - **Settled** - Three order findings are genuine and each is one move: LanguageTags places Installation after Usage, aiopurpleair places Getting Started after Installation, and PlexCleaner places Questions or Issues immediately after the Table of Contents where the order now puts it ninth. - - **Settled** - Two placement findings are genuine: KiCadLibrary carries a `## TODO` after `## License`, which the "TODO.md" rule already forbids, and HomeAutomation-Config renders the license shield twice, once outside the License section. - - **Settled** - MediaTools carries a `NuGet Pre-Release` shield that renders the same version as its `NuGet Release` shield, and it is dropped on that repo's next visit. The check does not report it, because a shield class is a floor and an extra shield is never a finding. - - **Settled** - Blog is the only repo carrying a `3rd Party Tools` table today, and it needs both fixes the rule now states: drop the License column, which the audit reports since 2026-08-09, and rewrite all three roles, since "theme, vendored under `themes/`" and "web server, serving the built site and the redirects" describe this repo's wiring where "static site generator" describes the tool correctly and differs only by its opening capital and its full stop. - -- **Bring each repo's `3rd Party Tools` entries onto the shared catalog.** Measured on 2026-08-09: 57 findings across four repos, a link, a description, or an ordering that disagrees with [`spec/third-party-tools.json`][third-party-tools], plus the one License column [`spec/readme-structure.md`][readme-structure] forbids. - - **Blocked by** - Nothing. - - **Issue** - None filed. - - **Checked** - Every repo's default branch on 2026-08-09, with the hub read at its own `develop`, which now conforms. - - **Settled** - The bulk is absent descriptions rather than wrong ones: 48 of the 57 are a tool listed with no description at all, across LanguageTags, MediaTools and PlexCleaner, and PlexCleaner alone accounts for 25 of those and 27 findings overall. Of the remaining nine, three describe a tool differently from the catalog, four link it differently, one is Blog listing Hugo, PaperMod, Caddy out of alphabetical order, and one is Blog's License column. - - **Settled** - Twelve tools already appear in more than one repo, which is what makes the catalog worth having before the 17 repos owing the section write their own wording for each. - - **Settled** - Four tools are already linked by two different URLs across the fleet, and the catalog picks one each: GitHub Actions takes `github.com/actions`, Dependabot takes `github.com/dependabot`, Nerdbank.GitVersioning takes the project repo rather than its marketplace action, and uv takes `docs.astral.sh/uv/` to match ruff. The hub was the outlier on the first two and is fixed. - - **Settled** - PlexCleaner lists Bring Your Own Badge as a tool, so the retired badge service has a fourth touchpoint beyond the three rendering it, and that entry goes with the same deletion. - - **Settled** - The catalog is a standard set and not a complete one, so a tool only one repo uses is unaudited. Of the 36 tools the fleet lists today, 24 are used by exactly one repo and are declared only so the second adopter copies rather than invents. - -- **Work off the reference-link naming and grouping backlog.** Measured across all 22 repos on 2026-08-08: 55 letter findings on naming and 27 drift findings on grouping. - - **Blocked by** - Nothing, and each repo's block is one edit. - - **Issue** - None filed. - - **Checked** - Every repo's default branch on 2026-08-08, with the hub read at its own `develop`, which now conforms. - - **Settled** - The naming half was already the fleet's practice before it was written down: 119 of 122 shield references end `-shield` and 514 of 532 URI references end `-link`, and `actions-link`, `releases-link`, `issues-link` and `discussions-link` are unanimous across every repo carrying them. - - **Settled** - The two real naming inconsistencies are the repository root, which 10 of 20 call `github-link` and the rest name for the project, and `./LICENSE`, which 9 repos call `license-link` where a repo-local path is a bare reference. - - **Settled** - The grouping half is drift rather than letter because it is not met: the fleet carries seventeen distinct group-header names, and two repos, NxWitness with 116 definitions and ESPHome-NonRoot with 45, carry no group headers at all. - - **Settled** - KiCadLibrary is the largest single block at 22 naming findings, almost all of them repo-local paths named `-link`. - -- **Delete the retired `byob.yarr.is` last-build badge from the three repos still carrying it.** The service is deprecated and the badge is not required by any shield class, so the fix is a deletion rather than a replacement. - - **Blocked by** - Nothing, and each repo's fix is deleting one shield line and one reference definition. - - **Issue** - None filed. - - **Checked** - Each repo's default branch on 2026-08-08, with the endpoints requested the same day: MediaTools and KiCadLibrary both return **HTTP 404**, so they already render a broken badge, and ESPHome-NonRoot still returns 200. - - **Settled** - The audit reports it, so this does not rely on anyone remembering: `deprecatedShields` in [`spec/readme-sections.json`][readme-sections] carries the retired service and the check fires on exactly those three repos. - - **Settled** - A dead badge is worse than an absent one, because it renders broken rather than missing and a visitor cannot tell a retired service from a failing build. - - **Settled** - All three repos are already non-conformant on other grounds, so this rides their next visit rather than earning a pass of its own. - -- **Decide where `## Build Artifacts` belongs.** LanguageTags and aiopurpleair both carry it, opening with the same `**Build process and artifacts**:` line and covering package, versioning, and publishing. - - **Blocked by** - Nothing. - - **Issue** - None filed. - - **Checked** - Both repos' default branches on 2026-08-08, where the section is the only one recurring across repos that [`spec/readme-sections.json`][readme-sections] does not name. - - **Open** - Whether it becomes a named optional section, folds into `Build and Distribution`, or moves to [`WORKFLOW.md`][workflow], since its content overlaps both. - - **Settled** - It is not a finding today. An unnamed heading is dropped before the order comparison, so the two repos carrying it pass, which is why this is a decision rather than a defect. - -### Two Project Types and a Shared C++ Style - -One pull request extending the type model with the two types the fleet already needs, plus the shared style the `cpp` type has no canonical for. - -**State** `ready`. **Touches** [`spec/project-types.json`][project-types], [`catalog/snippets/`][snippets], [`CODESTYLE.md`][codestyle]. **Cost** one hub edit plus a carried `CODESTYLE.md` re-vendor. - -- **Add a linter-only Python type for codegen and boilerplate Python.** Code that runs during another tool's build to emit generated source ships no unit tests and no coverage and needs only the linter. - - **Blocked by** - Nothing. - - **Issue** - None filed. - - **Checked** - `develop` at `1ed0cc8` on 2026-08-03. - - **Open** - Nothing. - - **Settled** - It stays distinct from the existing `python` type, which is utility code that can and should carry unit tests and coverage, as in PlexCleaner. - - **Settled** - ESPHome-Config stays `source-only` until it exists and its reclassification is deferred, so its one outstanding validation finding is accepted meanwhile. - -- **Add a fleet-standard clang-format config for the `cpp` type.** A catalog snippet plus a `CODESTYLE.md` C++ section, the analogue of the shared ruff config. - - **Blocked by** - Nothing. - - **Issue** - None filed. - - **Checked** - `develop` at `1ed0cc8` on 2026-08-03. - - **Open** - Nothing. - - **Settled** - It exists so the `cpp` clang-format check references one canonical style rather than each repo inventing its own, and the ESPHome-Config agent's proposed file is the base. - -### How a Hugo Repository Carries Its Theme - -One pull request deciding what the `hugo` type says about a theme, which it declares nothing about today. - -**State** `decision`. **Touches** [`spec/project-types.json`][project-types] and [`spec/type-model.md`][type-model]. **Cost** one hub edit, and it becomes the type's contract that a second generator inherits. - -- **Decide the theme carry mechanism as a question about the type rather than about Blog.** The candidates differ along the same axis the carried-content clusters are about. - - **Blocked by** - Nothing. - - **Issue** - None filed, and [#456][issue-456] and [#558][issue-558] carry the type's intake. - - **Checked** - `develop` at `b82c1a3` on 2026-08-05. - - **Open** - Which of three the type requires, the vendored copy Blog ships, a submodule pinned to an upstream ref, or a separate fleet-owned repository the site consumes. - - **Settled** - A vendored theme is a copy that goes stale with nothing detecting it, and a submodule is a pin Dependabot can see, which is the whole difference. - - **Settled** - Three details the intake predicted are wrong against what Blog runs, so planning from the prediction encodes requirements the repo does not meet: the theme is vendored with no recorded upstream ref rather than a Dependabot-tracked submodule, the generator is pinned by version and hash rather than run at latest, and the deploy is a separate dispatch rather than a tag cut last after the live check. - - **Settled** - What held is that the deploy is a publish, the type is named for the generator with the generic checks phrased so they do not name it, and the URL parity gate asserting a floor on the golden list length before comparing is the check of record. - - **Settled** - Promoting the generator-agnostic `hugo` checks to a shared type when a second generator arrives is a registry edit by construction, per [`spec/type-model.md`][type-model] "Generators". - - **Settled** - The `copilot_code_review` rule in both ruleset payloads gates no merge today, because gated Copilot review is an invite-only beta, which deserves a sentence near the merge gate so no repo reads the rule as the enforcement and relaxes the manual discipline holding the line. - -### Locally Required Secrets - -One pull request giving a repo a declared way to say what it needs at runtime, the way GitHub-stored secrets are already declared. - -**State** `decision`. **Touches** [`spec/secrets.json`][secrets] and its schema, [`spec/audit.py`][audit], and the hub's own `.gitignore`. **Cost** one hub edit plus adoption per repo that deploys. - -- **Make a gitignored secrets directory the fleet standard and declare its contents.** The required set is discoverable only by reading the deploy today. - - **Blocked by** - Nothing. - - **Issue** - None filed. - - **Checked** - `develop` at `1ed0cc8` on 2026-08-03, where [`spec/secrets.json`][secrets] covers only the Actions and Dependabot stores and the hub carries neither the directory nor a `.gitignore` entry for one. - - **Open** - Nothing on the local half, and the GitHub half below is the same axis rather than a separate problem. - - **Settled** - The pattern already runs in the fleet in two shapes, HomeAutomation-Config keeping a gitignored secrets directory of env files and Docker secret files, and ESPHome-Config keeping a gitignored `secrets.yaml` beside a committed `_secrets.yaml`. - - **Settled** - The committed file carries the required names with dummy values, so the shape of the requirement is in git while the values never are, which is the split the GitHub side already gets from `requiredSecrets`. - - **Settled** - Blog needs it immediately, since it deploys on the proxmox host through HomeAutomation-Config's Docker Compose stack and carries the copy destinations and the internal URI. - - **Settled** - Adopting it in the hub comes first, since the hub carries neither piece. - - **Settled** - The GitHub side has the same missing axis, surfaced by the `hugo` type, since a deploy's credentials are per-environment secrets and variables while `stores` is a closed enum of `actions` and `dependabot`, and [`spec/audit.py`][audit] seeds its map with those two keys and indexes it unguarded, so adding an `environments` value raises a key error for every repo whose publish maps to that mechanism. - - **Settled** - An optional `environments` block is legal in [`spec/secrets.schema.json`][secrets-schema] so a repo may declare its per-environment names, and no tool reads one where it exists, which is honest and is not a gate, so a clean audit says nothing about whether an environment is configured. - -### The Docker Image Freshness Rule - -One pull request stating that an agent never assumes a Docker image is present locally, however recently it pulled one. - -**State** `ready`. **Touches** [`GOVERNANCE.md`][governance], and [`OPERATIONS.md`][operations] if the mirrored one-liners move with it. **Cost** one hub edit, plus a carried re-vendor if the rule lands in a carried section. - -- **State the always-pull default and the explicit pull where the flag does not apply.** A background prune can remove an image between two commands of the same session. - - **Blocked by** - Nothing. - - **Issue** - None filed. - - **Checked** - `develop` at `1ed0cc8` on 2026-08-03, where the four documented lint invocations already carry the always-pull flag and no rule states why. - - **Open** - Where it lives, since "Running the Linters Locally" is scoped to the four lint tools while the rule covers any container an agent starts, and whether it is carried, since every repo runs the same images from the same instructions. - - **Settled** - What is missing is the rule rather than the one-liners, since an agent composing an ad-hoc `docker run` drops the flag precisely because it believes the image is cached. - - **Settled** - The honest limit stops the flag reading as the whole answer, since `docker run` against a registry tag re-pulls an absent image on its own, so the cases that break are a locally built tag with no registry to pull from, and any command that branches on the image being present such as `docker image inspect` or `docker images`. - -### Hub-Hosted Reusable Workflows - -One pull request per stage moving a standard workflow out of every repo and into the hub as a `workflow_call` task, with a downstream caller stub and a composite-action hook for what is genuinely repo-specific. The design, the hook contract, the pin policy and the staged rollout are in [`docs/reusable-workflows.md`][reusable-workflows-doc], and the burn-down is [`reports/workflow-reuse.md`][workflow-reuse-report], regenerated by `python3 spec/workflow_reuse.py --report`. The merge-bot task shipped with the design and its adoption is a sweep, so this cluster starts at the gates. The stage-by-stage completion state, one checkbox per hub change and per adopting repo with the evidence that closed it, is that doc's "Rollout" section, and a session resumes from there rather than from here. - -**State** `ready` for the gates, `blocked` on the gates for everything after. **Touches** the hub's `.github/workflows/`, [`spec/files.json`][files], [`catalog/snippets/workflows/`][workflows], and [`WORKFLOW.md`][workflow] where a guarantee names a copied job. **Cost** one hub edit per stage plus an adoption per repo on its next visit, and no re-vendor beyond the stub each stage introduces. - -- **Host the gates: `validate-task.yml` with a `validate` hook, and `test-pull-request-task.yml` with the fixed aggregator.** The hub owns the per-type doc-lint block once, the hook carries a repo's own tests, and the stub carries the trigger shape, operational or release. This stage is where the hook fallback is first proven live, on the hub for the default and on a pilot for the override. - - **Blocked by** - Nothing. - - **Issue** - None filed. [#585][issue-585] and [#729][issue-729] are settled inside this stage, the first by the operational stub's trigger and the second by the one place the hub validate task pins or floats its `uvx` tools. - - **Checked** - `develop` at `7c67328` on 2026-08-15, where the report counts 20 copies of `test-pull-request.yml` in 13 variants and 13 copies of `validate-task.yml` in 11, and the doc-lint block (markdownlint, cspell, actionlint, editorconfig-checker) repeats in every one. - - **Open** - Whether the per-type lint steps are selected by an input the stub sets or read from the repo's registry entry through a hub checkout at `github.job_workflow_sha`, since the second needs no per-repo input and the first needs no network read. - - **Open** - Whether a `validate` hook that runs a domain compile (an ESPHome build, a KiCad ERC) is one hook or several, given the two repos carrying such a step run it as a separate job today. - - **Settled** - Pilots are HomeAutomation-Config, the smallest operational tree, then a C# repo, so both trigger shapes are exercised before the sweep. - - **Settled** - The step gated on `hashFiles('.github/actions/validate/action.yml') != ''` runs the caller's hook from its own checkout, else the default from a hub checkout under `.hub/`, and a local composite action resolves at step time from the workspace, which is what makes the fallback expressible at all. - -- **Host the pure functions: `get-version-task.yml` and `publish-plan-task.yml`.** Neither has a repo-specific line, and the plan job is missing where D4.1 needs it. - - **Blocked by** - The gates, only for sequencing, since a repo adopts one stub per visit and the gates come first. - - **Issue** - None filed. - - **Checked** - `develop` at `7c67328` on 2026-08-15, where 5 of 8 `get-version-task.yml` copies are identical and all 3 `publish-plan-task.yml` copies are. - - **Open** - Nothing. - - **Settled** - PlexCleaner carries no `plan` job, so its next scheduled run ships a Dependabot bump, and the hub-hosted plan job is the fix rather than a per-repo copy. - -- **Host the release chain: `build-release-task.yml` with `build-<target>` hooks, `publish-release-task.yml`, and the Docker core.** The orchestration is generic and the target list is per repo, which the hooks express without a per-repo copy of the orchestrator. - - **Blocked by** - The pure functions, since the release task calls both. - - **Issue** - None filed. - - **Checked** - `develop` at `7c67328` on 2026-08-15, where the report counts 10 copies of `build-release-task.yml` in 6 variants, 17 of `publish-release.yml` in 12, and 5 of `build-docker-task.yml` in 4, with the Docker core identical in every copy. - - **Open** - The `matrix` input shape for a multi-image Docker repo, and whether a base-image build is a hook or a second task the stub calls first. - - **Settled** - The three no-asset release shapes the fleet runs today collapse into `expect_release_assets`, and PhotoCleaner and PlexCleaner pilot, then the NuGet, PyPI and Docker-only repos. - - **Settled** - Vanilla Docker repos need only `image` and build-args, ESPHome-NonRoot adds a `docker-prepare` hook for its upstream pin, and NxWitness adds the matrix hook and `build-base`, in that order. - -- **Host the type-specific tasks: Docker Hub readme, upstream-version tracking, deploy-site, codegen, and the date badge.** Each with its hook, and the two string-command inputs the catalog carries today (`transform-run`, `resolver-command`) become hooks. - - **Blocked by** - The release chain, since the readme task replaces the in-job description push. - - **Issue** - None filed. - - **Checked** - `develop` at `7c67328` on 2026-08-15, where each of these has one or two carriers. - - **Open** - Whether ESPHome-NonRoot's second tracker, whose bump waits for a human, is the same task with `auto-merge: false` or stays repo-local. - - **Settled** - The `operational-vs-release-workflow` skill's note that a target-agnostic target list is "intentionally not done" is retired by this stage rather than before it, since it is true until then. - -- **Decide the three merge-bot inputs the design leaves open.** The `delete-branch` default, the Dependabot semver-major filter two repos carry, and a `requiredHubUses` audit contract. - - **Blocked by** - Nothing, and each is a maintainer call rather than a finding. - - **Issue** - None filed. - - **Checked** - `develop` at `7c67328` on 2026-08-15, against the 16 downstream copies read for the design. - - **Open** - All three, stated in [`docs/reusable-workflows.md`][reusable-workflows-doc] "Open Decisions". - - **Settled** - Neither blocks adoption: `delete-branch: false` is the hub's behavior and seven repos opt in, and the semver filter is a D8.1 conformance question for the two repos that carry it. - -- **Bring the Docker repos onto one multi-stage Dockerfile shape.** The build stages are inconsistent across the five Docker repos, and that is Dockerfile content rather than workflow content, so it rides beside the workflow migration rather than inside it. - - **Blocked by** - Nothing. - - **Issue** - None filed. - - **Checked** - Not measured. Raised by the maintainer on 2026-08-15 while reviewing the Docker family design, and the first action is a read of the five Dockerfiles. - - **Open** - Whether the shape is a `CODESTYLE.md` section, a `docker` type check, or both. - -### Review Cost and the Local Review Pass - -One pull request, after a measurement, stating what change size licenses and whether a local adversarial pass earns its place, which are one question because both are about where review cost goes. - -**State** `measure`. **Touches** [`GOVERNANCE.md`][governance] branching or review guidance, once the numbers exist. **Cost** a measurement first, then one hub edit plus a carried re-vendor. - -- **Measure review rounds against pull request size, and decide what the number licenses.** The data needs no new instrumentation, since the review history carries it. - - **Blocked by** - Nothing. - - **Issue** - None filed. - - **Checked** - `develop` at `1ed0cc8` on 2026-08-03. - - **Open** - The threshold, expressed as the size at which a change is split rather than as advice to keep changes small. - - **Settled** - For each recent pull request the record carries the diff size in files and lines, the number of rounds, and the findings per round, counting suppressed findings alongside threaded ones because they are the majority of what these loops produce. - - **Settled** - Two confounds bound any line drawn from the numbers, that a large change is usually also a novel one so size and unfamiliarity move together, and that a round finding something new is the reviewer working rather than evidence of a problem, so the metric is findings a smaller first cut would have surfaced earlier. - -- **Try local defensive-review subagents as a first pass, and measure what the pass is worth.** One agent per lens rather than one general reviewer. - - **Blocked by** - Nothing. - - **Issue** - None filed. - - **Checked** - `develop` at `b82c1a3` on 2026-08-05. - - **Open** - Whether the overlap is large enough to shorten the remote loop rather than to add a step in front of it, which is what running both for a stretch measures. - - **Settled** - The remote loop is where most of a session's tokens and wall-clock go, and it delivers findings one round at a time, which is the slowest available way to learn that a change had five problems. - - **Settled** - The trap is that a local pass finding nothing reads exactly like a clean change, and the next inference is that the remote review can be skipped, which is the one outcome the review contract exists to prevent, so the local pass is an input to the loop and never a substitute for the round the merge gate requires. - -### Where a Disproof Goes When the Reviewer Is Not Copilot - -One pull request routing the disproof record from the provider-agnostic contract, so an agent that never opens the provider runbook still knows where a proof lives after the thread closes. - -**State** `ready`. **Touches** [`GOVERNANCE.md`][governance] "PR Review Etiquette". **Cost** one hub edit plus a carried re-vendor of a byte-locked section, which is why it is not folded into the change that built the record. - -- **State that a disproof is recorded where it survives the pull request, not only in the thread.** The record exists in [`.github/copilot-instructions.md`][copilot-instructions] "Disproved Claims" and nothing agent-agnostic points at it. - - **Blocked by** - Nothing. - - **Issue** - None filed. - - **Checked** - `develop` at `756a53e` on 2026-08-07, where outcome 2 of "Every Finding Ends in an Action" ends at the thread, "Responding and Resolution Expectations" requires the proof and says nothing about where it then lives, and the only pointer to the runbook is scoped to provider mechanics. - - **Open** - Whether the destination is named in the byte-locked text at all, since a repository is free to keep its record elsewhere and a rule naming one file is a rule that has to be true in every copy. - - **Settled** - The write side is where the gap bites rather than the read side, because an agent following the loop is already routed to the runbook for mechanics and an agent posting a decline is routed nowhere. - - **Settled** - "Durable Knowledge and Self-Improvement" already requires durable knowledge to reach a committed file, so this states where one class of it goes rather than adding an obligation. - -### A Programmatic Reading of a Copilot Review - -One pull request, after a survey, deciding whether anything stands between this fleet's review loop and the raw prose of a Copilot review. Today `scripts/pr_review.py` reads the review body as text and holds a vetted inventory of the headings, collapsed sections, metadata labels and coverage wordings it recognizes, blocking on anything it does not. That design is correct for a prose surface and it carries a cost the maintainer has accepted deliberately: a wording change at GitHub blocks every open pull request in the fleet at once, until the inventory is updated. The cost is worth paying against a reviewer silently missing a raised finding, which is the failure it replaces, but it is worth paying only for as long as prose is the only surface on offer. - -**State** `measure`. **Touches** `scripts/pr_review.py` and the runbook section in [`.github/copilot-instructions.md`][copilot-instructions], once the survey says whether there is anything to move to. **Cost** a survey first, then either nothing or a rewrite of the reading layer, which is the larger of the two outcomes and the reason the survey comes first. - -- **Find out whether GitHub publishes a structured form of a Copilot review, and decide whether to read that instead of the prose.** A schema, an API surface, a published payload, or a maintained library, anything that would make a wording change a non-event rather than a fleet-wide block. - - **Blocked by** - Nothing. The prose reader ships either way, so this decides what replaces it rather than whether the loop has a gate. - - **Issue** - None filed here, and the ask is filed upstream as [GitHub community discussion 204320][copilot-review-schema], which asks for a versioned machine-readable schema carrying severity, category, suggestion and resolution state, rather than the human-facing prose an integration has to infer those from. It is unanswered, so it is a place to watch rather than a dependency to wait on. The prose reader and its vetted inventory shipped under [#607][issue-607], which is the change this would supersede. - - **Checked** - `develop` at `20916ad` on 2026-08-07, reading the live GraphQL schema by introspection and one review over REST, against the reader in `scripts/pr_review.py`. - - **Open** - Whether `bodyHTML` is a better surface than the Markdown body, since it arrives as a rendered tree whose structure survives a change in Markdown syntax, while leaving the wording drift the inventory exists for exactly where it is. - - **Open** - Whether any third-party library tracks this output, and whether depending on one is acceptable at all, given that [`scripts/README.md`][scripts] holds these scripts to the standard library with no third-party packages. - - **Open** - Whether the review's own inline threads and their metadata carry enough to derive coverage and suppression without reading the body, which would narrow the prose surface rather than replace it. - - **Settled** - The public API carries no structured Copilot review as of the date above. GraphQL `PullRequestReview` exposes `body`, `bodyText` and `bodyHTML` and no field naming a finding, a file count, or a withheld section, and REST returns the same prose body beside its ids and its state. - - **Settled** - The only Copilot-named types in the GraphQL schema are `CopilotCodeReviewParameters` and its input form, which configure review-on-push inside a branch ruleset and describe nothing about a review that has run, so the schema search that looks promising by name answers a different question. - - **Settled** - A negative finding is the deliverable as much as a positive one, and it is recorded here rather than re-derived, since the reading layer's design rests on prose being the only surface and that premise is worth re-checking rather than assuming. - -- **Find out which file a partial round skips, and why re-requesting never clears it.** The coverage reading shipped in #608 blocks on a partial round, and the record says the state is durable rather than transient. - - **Blocked by** - Nothing, though it is research rather than a change, and the reader already reports the state correctly. - - **Issue** - [#623][issue-623], filed from a downstream repository against the `PARTIAL` caveat's claim that the reviewer names no file list. The reading that surfaces the state shipped under [#607][issue-607]. - - **Checked** - `develop` at `674a27a` on 2026-08-08, measured over 348 Copilot review bodies on the newest 120 pull requests here and 121 on the fleet's Blog repository, each read against the pull request's own changed-file list rather than against its counts alone. - - **Settled** - The reviewer does name a file list, and the caveat saying otherwise was wrong. It is a `| File | Description |` table carried by 91 of the 348 bodies, and every table row in the corpus belongs to one of those tables. - - **Settled** - The table names the unread file on exactly one round of the seven, which states 16 of 17 and names 16, omitting `GOVERNANCE.md`. That round is also the only evidence on record that the unread file is a real file rather than an artifact of counting. - - **Settled** - It cannot be read as coverage anywhere else. It names the whole changed set on partial and fully covered rounds alike, including all seven partials on Blog, while one round here states 61 of 62 and names 50, another states 33 of 33 and names 32, and a third names `GOVENANCE.md`, the reviewer's own spelling and a path no diff carries. A reading identical under both outcomes discriminates neither. - - **Settled** - Three of the four partials here carry their table on the round before a push, describing the diff that push replaced, so the comparison is head-scoped like the counts and reports no table rather than a stale list of unreviewed files. - - **Open** - Whether a partial round is worth escalating to GitHub at all. One named file on one round is a starting point rather than the pattern an escalation needs. - - **Settled** - It is durable rather than flaky. Four pull requests and seven rounds (#476, #479, #592, and the #609 promotion), and **every later round repeated the identical ratio**. A re-request has never cleared one, so the remedy the digest first stated was wrong and now says so. - - **Settled** - Size does not predict it. The partials changed 502, 629 and 961 lines, while fully covered pull requests here reach 33 files and 2,219 lines. - - **Settled** - The reviewer counts the file and does not read it, rather than losing it earlier. The stated denominator equals the API's own `changedFiles` on **103 of 104** pull requests, the exception being one whose branch shrank between rounds. - - **Settled** - Splitting remains a real remedy for a feature branch and is unavailable for a promotion, whose head is `develop`, so a promotion carrying a partial round is a maintainer decision by construction. - -### A Resolve the Loop Cannot Perform and a Thread Nobody Can Find - -The review loop ends by replying on a thread and resolving it, and both halves failed on one pull request in ways the runbook describes nowhere. The resolve mutation was refused by the agent harness's own permission layer before any request left the machine, seconds after the reply mutation carrying the identical thread id had succeeded, so the refusal was neither GitHub's nor the id's. Handing the resolve to the maintainer then failed a second time, because the digest names a thread by its `PRRT_` node id, that id appears nowhere in the GitHub interface, and the person asked to resolve it could not find what to click. - -**State** `ready`. **Touches** `scripts/pr_review.py`, the runbook section in [`.github/copilot-instructions.md`][copilot-instructions], and [`OPERATIONS.md`][operations]. **Cost** one pull request, since the query change is one field and the runbook change is one paragraph. - -- **Carry a thread's own web address beside its node id, so a resolve can be handed to a person.** `Q_THREADS` selects `id`, `isResolved`, `path`, `line` and the first comment's `author` and `body`, and not its `url`, so the digest can name a thread and cannot point at it. Selecting `url` and printing it beside the id makes the hand-off one click. - - **Blocked by** - Nothing. - - **Checked** - `develop` at `0e4a1c2` on 2026-08-08, reading `Q_THREADS` in `scripts/pr_review.py` against the digest line that consumes it. - - **Detail** - The two identifiers are not interchangeable and neither is derivable from the other without a query. A `PRRT_` node id is what a mutation takes, and a `#discussion_r` fragment is what the web page anchors on. - - **Detail** - The evidence is [#620][pr-620], where a thread was handed over by node id and the reply was that it could not be found. - -- **Give the runbook a shape for a write the harness refuses, which it currently has none for.** Its list of dead paths is entirely GitHub's own refusals, a silent no-op, a 422, and the wrong bot login for the API in use, so a local refusal matches none of them and reads as a bad identifier, which invites the retry a blocked write must never get. - - **Blocked by** - Nothing. - - **Checked** - `develop` at `0e4a1c2` on 2026-08-08, against the known-non-working-paths list in the runbook. - - **Detail** - The distinguishing evidence is that a reply on the same thread id, in the same session, had already succeeded and returned a comment url, so the identifier was demonstrably good. - - **Detail** - What cleared it was a permalink and a human click, and the durable remedy is a permission rule in host settings. That is host state rather than repo content, so it belongs in the runbook as a note rather than in a committed configuration file. - -- **Confirm a resolve by re-reading the thread rather than by the mutation returning.** `reply` already exits 63 where the resolve did not report the thread resolved, which is the right shape, and a loop driving `gh api` by hand gets no exit code at all and so cannot notice. The rule worth writing down is that the state is the evidence. - - **Blocked by** - Nothing. - - **Checked** - `develop` at `0e4a1c2` on 2026-08-08, reading the exit-code table in the `scripts/pr_review.py` module docstring. - - **Detail** - This is the failure the suppressed-findings count already exists for, where a step that stopped running reads exactly like a step that passed. - -### Watching a Downstream Pull Request Touch Hub-Owned Content - -One pull request adding the observer the fleet has no equivalent of, reading merged pull requests across the fleet and resolving every changed path against what the hub declares it owns. The tools today read standing state, so a divergence is visible only once it is already there, and a repo-local file the manifest never names is invisible at every stage. - -**State** `ready`. **Touches** a new `spec/carry_watch.py` with its self-test, [`reports/`][reports], [`AUDIT.md`][audit-doc], and [`.github/workflows/validate-task.yml`][validate-task]. **Cost** one hub script, hub-only, plus a first run whose output is a triage backlog rather than a change. - -- **Read merged fleet pull requests and classify each changed path against the manifest.** The gap is a whole reading rather than a missing field, since nothing anywhere enumerates pull requests. - - **Blocked by** - Nothing. - - **Issue** - None filed. [#633][issue-633] is the instance that prompted it, raised by a downstream agent after the maintainer noticed it editing hub-managed CI files, and nothing mechanical had reported that. - - **Checked** - `develop` at `c2ce145` on 2026-08-08, reading [`spec/files.json`][files], [`spec/divergences.json`][divergences] and [`registry/repos.json`][repos], and running the enumeration query live against the owner. - - **Open** - How a window wider than a thousand results is split, since the GitHub search API caps there and a silent truncation is the false clean this whole class of tool exists against. The split has to be visible in the output rather than inferred. - - **Open** - Whether a `SECTION` or `CONTRACT` classification reads content in the same pass or defers to a human, since the path alone says a carried file moved and not which region of it. - - **Settled** - The enumeration is one query rather than a per-repo loop, measured live: `search(query: "org:ptr727 is:pr is:merged base:main merged:>=<DATE>", type: ISSUE)` returned 112 pull requests over a fortnight with per-pull-request `files` and `repository` inline. The `base:` term comes from each repo's registry `groundTruthBranch` rather than a hardcoded `main`. - - **Settled** - It belongs in `spec/` beside [`spec/fidelity_honesty.py`][fidelity-honesty], which is its sibling in every respect that decides placement, being owner-initiated, absent from CI, an importer of `audit` as a library, and a writer of a generated report. [`scripts/`][scripts] holds gates run against one repo named by `--root`. - - **Settled** - The classes are `OVERSTEP` for a `verbatim` whole unit, `SECTION` for a path declaring verbatim sections, `CONTRACT` for an `interface` unit, `GAP` for a path the hub tracks that the manifest never names, and `CANDIDATE` for a path absent from the hub changed in a pull request that also touched one of the others. - - **Settled** - `intent` and `presence` units are deliberately not watched, being downstream-owned by design, and that exclusion is what makes suppression keyed on the ledger correct rather than over-broad. - - **Settled** - `GAP` plus `CANDIDATE` is the pair that means a downstream wired local tooling into a workflow the hub authored, which is exactly `ptr727/Blog#69`: it changed `.gitattributes`, `.github/workflows/validate-task.yml` which is a ledger `gaps` entry dispositioned `investigate`, and a `checks/check-eol-pins.py` the hub has never heard of. A sibling pull request shows the same shape over a whole `checks/` tree. - - **Settled** - Triage needs no new store. [`spec/divergences.json`][divergences] already carries `upstream-candidate` in its disposition vocabulary, meaning the downstream carries an improvement the hub should adopt, and nothing in the ledger uses it today. A dispositioned pair prints with its disposition and everything else renders `UNTRIAGED`, which is what [`reports/divergences.md`][divergences-report] already does. - - **Settled** - The rule goes in [`AUDIT.md`][audit-doc] section 9 rather than [`GOVERNANCE.md`][governance], whose sections are verbatim fleet law, so an edit there puts every downstream repo into verbatim drift until re-vendored for a sentence that is procedure rather than law. - - **Settled** - Two floors are not optional. A run reading zero pull requests reports that rather than a clean zero, and a repo the search surfaces with no registry entry is reported separately, which feeds "Registry Membership Coverage" above. - - **Settled** - This is not the deferred audit automation recorded under "Standalone Chores". That entry rejected three scheduled and hook-driven shapes on three blockers, and this is owner-run and on demand like [`spec/fidelity_honesty.py`][fidelity-honesty], so it lands on none of them. - -### A Disproof About Carried Text Has Nowhere Fleet-Wide to Live - -One pull request deciding whether the review record admits a second class of entry, for a finding raised against text the whole fleet carries rather than against one repository's own file. The record's shape and its per-repository rule are both right and neither covers this case, so the deliverable is a decision about the category rather than a rewrite of what exists. - -**State** `decision`. **Touches** [`.github/copilot-instructions.md`][copilot-instructions], and possibly [`spec/section-model.md`][section-model] if the answer is a new carried section. **Cost** one hub edit, plus a fleet-wide re-vendor only if the entries themselves become carried. - -- **Decide where a disproof about carried text lives, given that every repository carrying the text will meet the same finding.** The record's preamble says the entries are the hub's own, that a repository carrying the file keeps the shape and the rules rather than the findings, and that it records what it has proved itself. That is correct for a finding about one repository's tree and wrong for one about a canonical every repository holds a copy of. - - **Blocked by** - Nothing. - - **Issue** - None filed. - - **Checked** - `develop` at `7bc6978` on 2026-08-10, against the `keys_unsorted` entry at `.github/copilot-instructions.md` and the preamble sentence beginning "The entries are this repository's own". - - **Open** - Whether the answer is a second entry class in the record marked as carried, a fleet-level record somewhere else, or nothing at all on the grounds that independent re-derivation is worth its cost because it catches an entry that has gone stale. - - **Settled** - The case is real rather than predicted. A reviewer raised the `keys_unsorted` claim against the section 6 snippet at the hub, it was disproved by running both builtins on `jq-1.5-1-a5b5cbe`, and the same claim was then raised against a downstream repository's carried copy of the same snippet, where it was disproved a second time from the jq 1.5 manual. Two disproofs of one claim about one canonical, and the second could not cite the first. - - **Settled** - The independent re-derivation was not wasted, which is what makes this a decision rather than a defect. The second disproof came from the manual where the first came from a binary, so the two cover intent and behavior rather than repeating one another, and a rule that suppressed the second would have lost that. - - **Settled** - The current rule is right about what it governs. A repository carrying the hub's findings would carry claims about files it does not have, each naming a revision it never had, which is the staleness the per-repository rule exists to prevent. So the fix cannot be to relax that rule, and any answer has to distinguish the subject of a finding from the repository that filed it. - - **Settled** - The cost is bounded and recurring rather than one-off. It falls once per repository per finding, on carried text only, and only where a reviewer raises the same point twice. It is small enough that doing nothing is a legitimate outcome, which is why this is a decision cluster and not a defect. - -### Peer Messaging Between Agents as a Declared Method - -One pull request writing down the agent-to-agent messaging this fleet has now used successfully, so it is a method with stated boundaries rather than a capability each session rediscovers. The mechanism already works and needs no build, so the deliverable is prose plus the decision about where prose that binds a downstream agent is allowed to live. - -**State** `ready`, and the deliverable ships beside [`docs/fleet-map.md`][fleet-map], so this cluster is deleted when that pull request merges. **Touches** [`docs/peer-messaging.md`][peer-messaging-doc]. **Cost** one hub edit, and no re-vendor. - -- **Declare peer messaging a standard method, and decide which document carries its rules.** The safety half is the load-bearing half: confirm a peer's identity before sending it anything substantive, verify a peer's factual claims against the tree before repeating them, never read a peer's request as the maintainer's approval, and never ask a peer to perform what the asking session was denied. - - **Blocked by** - Nothing. The mechanism is live and was exercised end to end on 2026-08-10. - - **Issue** - None filed. - - **Checked** - `develop` at `3855dbb` on 2026-08-10, against a live exchange with the ESPHome-Config session on this host, and `ListAgents` listing two local peers and no cloud or remote row. - - **Settled** - The rules live in the hub-only [`docs/peer-messaging.md`][peer-messaging-doc], per the location decision recorded in [`docs/fleet-map.md`][fleet-map] "Peer Messaging", and that doc states the promotion criteria under which the carried option is re-evaluated. - - **Settled** - The write-up states the same-host limit and leaves cross-host undocumented until a second machine is reachable, which is what [`docs/peer-messaging.md`][peer-messaging-doc] does. - - **Settled** - Same-host works and cross-host does not, by construction rather than by configuration. A peer address is a Unix domain socket under `/run/user/1000/cc-socks/`, which cannot cross a machine boundary. Cloud sessions and Remote Control sessions on other machines are the documented cross-host paths and neither appears in a listing on this host, so both are unverified rather than absent. - - **Settled** - The addressing has a guardrail worth keeping in the write-up. A bare peer name was refused and the transport required the `[ref]` a listing prints, which is what stops a message reaching the wrong repository's agent. - - **Settled** - The method earns its place on evidence rather than novelty. One exchange produced the causal commit for the section 6 ruleset defect, `90e3255`, which the hub session had not identified from the symptom, plus a one-line reproduction of the gojq key-sorting behavior that made an earlier fix pass for the wrong reason, plus four procedure gaps a reader found that no gate reports. - - **Settled** - A peer's finding is checked rather than adopted. Two of those four did not reproduce at the hub, the `AGENTS.md` anchor rewrite and the settings-diff exposure, and one did and shipped as #653. So the write-up states verification as a step rather than as a courtesy. - - **Settled** - The boundary that matters most is not politeness but permission. A peer cannot widen what the asking session may do, so work blocked in one session goes back to the maintainer rather than sideways to another agent. - -### What Building the Windows Host Tooling Surfaced - -Three findings raised while writing [`host-setup/windows/`][host-setup-windows], each about the Linux side or the fleet rather than about the new scripts, and none blocking them. - -**State** `ready` for the first two, `decision` for the third. **Touches** [`docs/host-setup.md`][host-setup-doc], and the three scripts under `host-setup/linux/`. **Cost** one hub edit each, and no re-vendor, since nothing under `host-setup/` is carried. - -- **Record why the host tooling carries no linter category, or decide that it should.** No installer on either platform manages `markdownlint`, `cspell`, `actionlint`, `editorconfig-checker`, `shellcheck`, `PSScriptAnalyzer` or `ruff`, and nothing states that as a decision, so the absence is correct and reachable only by inference. - - **Blocked by** - Nothing. - - **Issue** - [#671][issue-671]. - - **Checked** - `main` at `1d5b076` on 2026-08-11, where `readonly TOOLS=(git gh jq git-restore-mtime node python uv dotnet)` names no linter and no comment says why. - - **Settled** - The reasoning holds and is worth writing down once rather than per platform: each linter runs as a pinned image or through `uvx`, so the image tag fixes the version and a local run matches CI, and installing native copies would put a second unpinned version on the host and break exactly that. - - **Open** - Whether it belongs in [`docs/host-setup.md`][host-setup-doc] as a fleet fact, which is what covers Linux by the same sentence, or stays per platform where only the Windows README states it today. - -- **Report the `gh` git protocol in `setup-github.sh`, as its Windows peer does.** A host can pass every check the fleet runs while `gh` is configured for https, and a checkout made through `gh` then authenticates by token where every other checkout on that host authenticates by key. - - **Blocked by** - Nothing. - - **Issue** - [#672][issue-672]. - - **Checked** - Measured on the maintainer's Windows host on 2026-08-11, where `gh auth status` reports `Git operations protocol: https` against `gh` 2.97.0, an SSH key that signs and verifies, and `scripts/host_gate.py` exiting 0 over all seven declared tools. - - **Settled** - Reported rather than written, since rewriting a working authentication configuration is the operator's call, and `setup-github.sh` touches `gh` nowhere today. - - **Open** - Whether `--configure` should set it, which is the only part where the two platforms could still diverge. - -- **Align the Linux scripts onto "name one action" instead of "the last one given wins".** Overwriting `MODE` in the arg loop discards an intent silently, and it discards it in the dangerous direction: `--report --install` drops the safe action and keeps the one that changes the host. - - **Blocked by** - Nothing. - - **Issue** - [#673][issue-673]. - - **Checked** - `main` at `1d5b076` on 2026-08-11, where all three scripts document last-wins, no documented example passes two actions, `bootstrap.sh` passes exactly one per `run_tool` call, and no test asserts the behavior. - - **Settled** - The Windows tooling already refuses this way. That began as a constraint, since a PowerShell `param()` block records which switches were given and not their order, and the constraint produced the better behavior. - - **Open** - Nothing about the change itself, which is three `usage()` heredocs and three `parse_args()` bodies. The decision is only whether the fleet wants the stricter contract, and taking it deletes the differences-table row in [`host-setup/windows/README.md`][host-setup-windows] rather than leaving a permanent divergence. - -### Neither Host Bootstrap Has Run Against a Truly Fresh Host - -Two loaders exist so a copy-paste snippet takes a stock OS install to a configured dev host, and neither has ever been run that way. Everything either has behind it is a dry run or a read against an already-configured checkout, on a machine carrying most of the target tools already. That confirms the logic is internally consistent. It confirms nothing about a `winget` package id still resolving, a stock Debian netinst actually lacking `curl` the way the docs assume, `tar.exe` genuinely shipping on a given Windows image, or the interactive menu reading correctly on a real console. This needs a human watching a real run on a real fresh image and reporting back what broke, including anything that merely looked fine, since neither of those closes from a description of the logic. - -**State** `ready`. **Touches** [`host-setup/bootstrap.sh`][bootstrap] and [`host-setup/bootstrap.ps1`][bootstrap-ps1] and, if either run turns something up, whichever script under [`host-setup/linux/`][install-tools] or [`host-setup/windows/`][host-setup-windows] it drives. **Cost** VM time on the images each entry names, and an iteration round trip per finding, since a fix this file cannot verify is a fix that needs the same fresh image again. - -- **Run `bootstrap.sh` unattended against a fresh Debian and a fresh Ubuntu image, and again to confirm the second run is idempotent.** `--host --yes` finishing clean, with nothing to fix, is the signal. A re-run reporting no further changes confirms idempotency rather than assuming it. - - **Blocked by** - VM access to a current image of each, and one still-supported older release per distribution, since the contract's floors are meant to hold there too. - - **Issue** - None filed. - - **Checked** - `develop` at `82a87d3` on 2026-08-13, where this loader has existed since #674 and carries no record of a run against an image with nothing preinstalled. - - **Open** - Which images, who runs the pass, and whether a failure blocks the loader or is filed and worked separately, since a fresh-host pass can turn up findings well past what one pull request should carry. - -- **Run `bootstrap.ps1` unattended against a fresh Windows 10 image with no App Installer, and a fresh Windows 11 image, then again on each to confirm idempotency.** The Windows 10 case is the one that exercises the winget-missing remedy this loader prints but has never had checked against a real console. Windows 11 is the expected common case, App Installer and `winget` both present. `-Host -Yes` finishing clean on each, then a clean re-run, is the same signal as the Linux entry above. - - **Blocked by** - VM access to both images. - - **Issue** - None filed. - - **Checked** - Branch `feature/windows-bootstrap-loader` on 2026-08-13, adding this loader for the first time. It has run under `-DryRun` and against `PSScriptAnalyzer` on a dev machine that already carries `pwsh`, `winget`, and most managed tools, which is signal on the script's internal consistency and none at all on whether it survives a host it has not touched. - - **Open** - Same as the Linux entry: which images, who runs the pass, and how a finding routes back. - -## Standalone Chores - -Small work with no research to preserve, selectable one bullet at a time. - -- **Answer the symmetric reading of [`.editorconfig`][editorconfig], a path-specific section naming files that do not exist**, which is the half of [#633][issue-633] the `eol-coverage` check deliberately left open. The dead-pin reading it does ship is the `.gitattributes` side, and the same question on the other document is not the same shape: this repo's `[.github/workflows/*]` and `[catalog/snippets/workflows/*]` sections are legitimately broad, and the issue's own first attempt at it produced false positives because the matcher did not expand brace syntax, which [`scripts/repo_gate.py`][repo-gate] already implements. Measure the exemption against the live corpus before building the gate rather than after, since a stale exemption hands out a work list that damages correct documents, and decide whether `forward-declared` carries across or whether an editorconfig section needs its own marker. -- **Reconsider whether the pre-commit hook runs the doc gates now that they are diff-scoped.** [`scripts/README.md`][scripts] records the current decision and its reason, that doc linters stay out of the hook so it stays fast, which was sound when the only mode was a whole-tree sweep, and a diff-scoped run finishes in about a second. The failure it would prevent is the most repeated one on record, comment sentences wrapped across lines caught after the commit rather than before it. Weigh it against the standing preference for a fast hook. The other objection, a hook running the gate from the wrong directory and reporting its own false clean, no longer applies: the rule set, the file set, the diff, and the keys joining them are all read from the repository being scanned rather than from wherever the process stands. -- **Audit the fleet's shell surface by size and branching, and decide per script whether Python with unit tests is cheaper.** The evidence is the review record rather than a language preference, since a non-trivial shell script earns findings round after round while every gate under [`scripts/`][scripts] carries a test file under `scripts/tests/` and converges in one or two. The measure is lines, branch count, and the review rounds each has cost. `repo-config/configure.sh` and the agent-safety installer are the two worth measuring, and a bootstrap script that needs the Python it exists to install is not a rewrite worth having, which protects the installer more than the config script. -- **Make a table of contents standard for a long document rather than for the README alone.** [`spec/readme-structure.md`][readme-structure] fixes one at README position 4 and no other hub file carries one, which leaves the three longest documents without it, `CODESTYLE.md` at 516 lines, `GOVERNANCE.md` at 436 and `WORKFLOW.md` at 301, measured on `develop` at `3d1a0b1` on 2026-08-06. Settle the threshold in headings or lines so the audit can check it, and settle how it sits with the reference-link exception, since the four agent-instruction files keep inline links exactly because they are read one section at a time, which is the property that makes a contents list worth having in them. The mechanical constraint is that the list is filled by the Markdown All in One extension on save, so a file nobody opens in the editor grows a stale list, which is worse than absent because it is read as current. -- **Converge this repo's Python on the ruff configuration it already declares, then add the formatting half to the pre-commit hook.** `pyproject.toml` carries `[tool.ruff]` and [`spec/project-types.json`][project-types] declares `python.ruff.config`, yet no workflow runs ruff and the tree does not pass it, measured on `develop` at `6d020b1` on 2026-08-09 with ruff 0.16.2: `ruff format --check` reports 13 of 57 files would be reformatted, and `ruff check` reports 106 errors, of which 39 are auto-fixable. The largest groups are 24 `PLW1510` (a `subprocess.run` with no `check`), 17 `FURB167` (`re.M` for `re.MULTILINE`), 11 `EXE001` (a shebang on a non-executable file, which wants reading against the `eol-coverage` shebang set rather than fixed blindly), 9 `BLE001` and 9 `SIM117`. The hook deliberately ships without the ruff step for this reason, since a gate failing on the corpus it guards blocks every commit from the moment it lands, which is the measure-the-corpus-first rule applied to a gate rather than to an exemption. Decide whether CI gains a ruff job in the same pass, since a formatter enforced only by a hook is enforced only on the machines that enabled it. -- **Adopt the OCI annotation keys for Docker image metadata across the Docker repos**, replacing the ad-hoc and label-schema keys, per [#363][issue-363]. -- **Sweep the central package-version property to `Directory.Packages.props` fleet-wide**, since PlexCleaner sets it in `Directory.Build.props`, off the [`CODESTYLE.md`][codestyle] canonical. -- **Canonicalize Python linter-config placement on `pyproject.toml`**, since one cataloged repo uses a standalone ruff config plus a pyright config. Track it as a drift finding and fix it downstream. -- **Populate [reports/][reports] for the cataloged repos that still have no audit**, since a registry `status` of `cataloged` asserts a result only a committed report evidences. Nine of 22 have one, measured on `develop` at `3d1a0b1` on 2026-08-06. This is paced by maintainer capacity rather than blocked, since repos are brought up to spec as they are worked on. -- **Finish onboarding hardening**, from [#310][issue-310], making the [`AUDIT.md`][audit-doc] audit a required onboarding step and running the per-type cold-start self-tests tracked in [reports/conformance-matrix.md][matrix]. Every cold-standup cell reads not-tested today. -- **Decide whether the human entry points the README now carries belong in [`spec/readme-structure.md`][readme-structure]**, so a fleet repo is measured on them rather than reinventing them. The README routes by reader (browsing, adopting, blocked by a rule, reporting, an agent) in an optional Getting Started table, and answers adoption, divergence, and issue-reporting in the Installation, Configuration, and Questions or Issues slots the spec already orders. What is undecided is how much of that is fleet-general, since a repo shipping an application has a different reader set from a rules hub, and a per-section index was considered and declined because it trades brevity for a sync obligation to whatever the docs contain. This sits beside "The README Structure Rework" and is settled with it rather than before it. -- **Consider renaming this repo to reflect the audit-catalog identity**, which updates badge and link URLs across the fleet. -- **Revisit automating the audit**, explored and deliberately deferred, recorded so the reasoning is not re-derived. Three shapes were considered, a scheduled hub-driven audit publishing each report as a workflow artifact, the same thing committing the report back, and a pull-request hook in each downstream repo auditing itself against the current hub. Three things block all of them: until the fleet reaches stasis a scheduled run reports mostly noise, since a repo mid-onboarding is expected to be non-conformant, the hub has to be stable before downstreams audit against it because a hub change lands as fleet-wide findings the same day, and the downstream half is a catch-22 since a self-auditing hook is CI instrumentation the repos that most need it do not carry. Worth reopening once the fleet is onboarded and the hub goes a stretch without carried-content changes, and the artifact shape is the one to try first since it produces evidence without committing anything. - -## Fleet Sweeps - -Work that lands on a downstream visit rather than as a hub pull request, so it is not selectable here. The fleet is caught up periodically rather than after every hub change, which means a carried-content edit landing in the hub does not owe an immediate sweep and this list is expected to carry several entries at once. - -Blog is the pilot. A sweep is proven there before any fleet-wide rollout, because it is the smallest tree, `hugo` plus `source-only` with no build to break, cataloged and audited on 2026-08-05, and one of only two repos carrying `AGENTS.md` "Fleet Bootstrap" today, so a carried-section change can be observed arriving there. The other carrier is HomeAutomation-Config, which is `operational` and therefore exercises the direct-to-`develop` path rather than the pull request one, which is the second visit worth making rather than the first. - -Regenerate [reports/divergences.md][divergences-report] before using it as the work list, since it is a live pass over each repo's ground-truth branch and the committed copy is only as current as its last run. A stale ledger is the same hazard as a stale exemption, in that it hands out a work list measured against a tree that no longer exists. The reason this line used to give, that the committed copy still rendered `repo-config/configure.sh` under a re-vendor disposition, did not survive the check: that copy already carried the `retire` disposition, so the warning was true of the decision rather than of the file. What the 2026-08-09 regeneration actually moved was three rows, adding `AGENTS.md` "Fleet Bootstrap" as divergent at Blog and HomeAutomation-Config, and widening `GOVERNANCE.md` "Verification Discipline" and "Workflow YAML Conventions" from one repo to four. - -- **Re-vendor the changed `verbatim` content, which is one sweep covering seven files.** Every repo holding a copy of a changed section is byte-mismatched against the hub until it takes the new one, which the audit reports as stale rather than modified. - - **Hub state** - Done, verified `develop` at `3d1a0b1` on 2026-08-06 for the sections below, with the prose batch adding five more [`GOVERNANCE.md`][governance] sections, verified `develop` at `d791930` on 2026-08-07. - - **Outstanding** - The whole fleet, pilot on Blog first. - - **Issue** - None filed, and it is the follow-through [#489][issue-489] and [#379][issue-379] wait on. - - **Rides with** - The `configure.sh` retirement and the `.editorconfig` line from [#353][issue-353], since all three are the same visit. - - **Detail** - In [`AGENTS.md`][agents], "Context and Delegation Discipline" carries the wait rule's failure clause and "Where the Rules Live" carries a row for "Hub-Hosted Tooling". - - **Detail** - In [`GOVERNANCE.md`][governance], "Verification Discipline" carries the rule that a launched process is not a result and the rule that a change's checks are located before any is run, with CI's coverage not being that list, "PR Review Etiquette" carries the five outcomes that close a finding, "Repository Boundaries and Write Safety" carries the rule that a refused write is reported rather than re-shaped, and both "Representative Data in Agent-Authored Text" and "Hub-Hosted Tooling" are entirely new carried sections no downstream repo holds, which the audit reports as sections that never arrived rather than as drift. - - **Detail** - Three further [`GOVERNANCE.md`][governance] sections differ by a single word each, "Documentation Style Conventions", "Communicating with the User" and "Repository Details", where a format name took the capitalization [`CODESTYLE.md`][codestyle] "Markdown and Spelling" states, so they are byte-mismatched for a reason a reader of the diff would otherwise call cosmetic. - - **Detail** - Two comment lines in [`.markdownlint-cli2.jsonc`][markdownlint] took the same capitalization, and that file is `verbatim` and `whole`, so every downstream copy is byte-mismatched on a config nothing else changed about. - - **Detail** - [`CODESTYLE.md`][codestyle] is the fifth file, at `intent` rather than `verbatim`, so it reaches the fleet as a rule each repo adopts in its own copy, and the same mixed spelling waits in every downstream tree. - - **Detail** - [`.github/copilot-instructions.md`][copilot-instructions] is the sixth, also at `intent`, where "Reply and Thread Resolution Workflow" now leads with the hub's reply helper and keeps the hand-run mutations as the cross-owner and unreachable-hub path. A repo taking the old copy is not broken by it, since the mutations it documents still work, so this rides the visit rather than gating it. - - **Detail** - The same file's "Triggering and Polling" reads the reviewer bot's node id across the repo's newest pull requests rather than from the pull request under review, because the id is the reviewer account's own and is identical on every pull request in the repo, read as one value across all eight of the newest here on 2026-08-08. This is the other part that propagates a procedure rather than refreshing a hash, so a repo left on the old copy reads its own runbook as requiring a review on the pull request before the id can be read, and hands round 1 to the maintainer to seed through the UI whenever auto-review-on-open does not fire, which is the hand-off the mutation exists to remove. - - **Detail** - The prose batch rewrote punctuation in five `verbatim` [`GOVERNANCE.md`][governance] sections, "Branching Model", "Release Model", "Documentation Style Conventions", "PR Review Etiquette" and "Workflow YAML Conventions", so every downstream copy of those five is byte-mismatched and the audit reports it as stale. No rule changed meaning, so the re-vendor is a hash refresh rather than a propagation, and a repo taking the old copy is correct on the rule while wrong on the bytes. - - **Detail** - The [#578][issue-578] widening is one of the two parts of this sweep that propagate a rule rather than refreshing a hash, the runbook correction above being the other, so a repo left on the old copy is wrong on the rule and not merely on the bytes, which makes the pair the half to carry first. It touches three `verbatim` [`GOVERNANCE.md`][governance] sections, "Branching Model", "Communicating with the User" and "Operational Repositories", and the third of those matters most on the two `operational` repos that can act on it. [`WORKFLOW.md`][workflow] took a cross-reference in the same change and is `intent`, so nothing reports it. - - **Detail** - [`WORKFLOW.md`][workflow] is the seventh file and `repo-config/README.md` joins [`CODESTYLE.md`][codestyle] and [`.github/copilot-instructions.md`][copilot-instructions] at `intent`, where a punctuation-only edit produces no hash and therefore no audit finding at all. Nothing reports these, which is why they are recorded here rather than left to the run. `HISTORY.md` is `presence` and is each repo's own changelog, so its one fix owes nothing downstream. - -- **Adopt the merge-bot caller stub, which is one file per repo replacing the copied job bodies.** The audit reports the missing `merge-bot` caller job on every copy until the repo adopts, which is the work list. - - **Hub state** - Done on `develop`, where `.github/workflows/merge-bot-task.yml` is the task and the hub's own `merge-bot-pull-request.yml` is the stub. The stub a repo copies is in [`docs/reusable-workflows.md`][reusable-workflows-doc] "Adopting the Merge-Bot", and its pin is the first hub release carrying the task, so no repo can adopt before that release. - - **Outstanding** - Every repo carrying the file, 16 today, pilot on HomeAutomation-Config, then homeassistant-purpleair for the `rules` input. - - **Issue** - [#521][issue-521], whose hub half is done and whose sweep half this is. - - **Rides with** - The `verbatim` re-vendor above. - - **Detail** - The unused `GITHUB_TOKEN` grants #521 names are gone with the copy, since the task declares none and the stub sets `permissions: {}`. - - **Detail** - Two repos filter Dependabot by ecosystem and semver tier, and per D8.1 the filter drops on adoption unless the open decision in the cluster above lands first. - - **Detail** - The pilot records what the hub cannot prove, cross-repository resolution of the pin, the first Dependabot bump of it, and the `rules` input end to end, in its own audit report. - -- **Carry the `Local Verification` heading into every repository's `OPERATIONS.md`.** The heading leads the file and states what verifying a change there requires, naming the part of the repo's contract CI structurally cannot exercise, and a repo whose gates are entirely in CI says that under it rather than omitting it. - - **Hub state** - Done, verified `develop` at `8e10a2c` on 2026-08-06, where [`spec/section-model.md`][section-model] and [`STANDUP.md`][standup] declare six headings and this repo's own [`OPERATIONS.md`][operations] leads with the section. - - **Outstanding** - Every repo carrying an `OPERATIONS.md`, which is every repo, since none holds the heading yet. - - **Issue** - [#597][issue-597], filed from a downstream repo whose pre-merge gate sat under a heading of its own invention and was skipped by an agent following every carried rule correctly. - - **Rides with** - The `verbatim` re-vendor above, since the carried [`GOVERNANCE.md`][governance] rule that points at the heading lands in the same visit and neither half works alone. - - **Detail** - The audit reports nothing here today, because `OPERATIONS.md` is presence-checked only, so a repo using none of the declared headings passes. The heading check is [#523][issue-523]'s cluster, "Content in the Wrong File", and until it ships this sweep is verified by reading each file rather than by a run. - - **Detail** - A repo that already documents a local gate has the content and not the location, so the visit is usually a re-heading rather than new prose, and the prose it does need is the sentence naming what CI cannot reach. - -- **Retire the downstream `repo-config/configure.sh` copies.** Delete the copy as each repo is next worked on and run the hub's script against it by name. - - **Hub state** - Done, verified `develop` at `3d1a0b1` on 2026-08-06, where [`spec/files.json`][files] no longer declares the file and [`spec/divergences.json`][divergences] carries it under the `retire` disposition. - - **Outstanding** - Six repos, NxWitness, aiopurpleair, homeassistant-purpleair, ESPHome-NonRoot, VSCode-Server-DotNetCore and LanguageTags. - - **Issue** - None filed, and [#580][issue-580] carries the decision. - - **Rides with** - The `verbatim` re-vendor above. - - **Detail** - Nothing asks a repo for the file and nothing reports its absence, which makes this a visit-ordered chore rather than a gate. - - **Detail** - The six carried a fork predating the payload-driven check mode, which is the drift this removes rather than converges. - -- **Drop the `.editorconfig` analyzer relaxation across six C# repos.** The hub side is done and the tree confirms it. - - **Hub state** - Done, verified `develop` at `3d1a0b1` on 2026-08-06, where the analyzer severity property appears nowhere in `.editorconfig`. - - **Outstanding** - Six C# repos, sequenced in the issue so PhotoCleaner's 362 sites do not gate the other five. - - **Issue** - [#353][issue-353], which stays open on the downstream half alone. - - **Rides with** - The `verbatim` re-vendor above. - -- **Close out the two downstream acknowledgements that hold their issues open.** Neither is hub work. - - **Hub state** - Done, verified `develop` at `1ed0cc8` on 2026-08-03, where the manifest gap [#379][issue-379] raised is closed by `repo-config/settings.json` reaching [`spec/files.json`][files], and the `configure.sh` half has since been retired outright. - - **Outstanding** - Financial-Modeling's acknowledgement and re-vendor for [#379][issue-379], and the re-vendor [#489][issue-489] leaves. - - **Issue** - [#379][issue-379] and [#489][issue-489]. - - **Rides with** - The `verbatim` re-vendor above. - -- **Widen the operational lint trigger to `develop` on four repos.** Each triggers on a pull request to `main` only and therefore runs nothing at all on a pull request into `develop`. - - **Hub state** - Done, verified `develop` at `b82c1a3` on 2026-08-05, where the change is prose and spec, so it fixes no downstream repo by itself. - - **Outstanding** - Four repos, HomeAutomation-Config, ESPHome-Config, HomeAssistant-Config and Vantage-Config, one line each. - - **Issue** - [#585][issue-585]. - - **Rides with** - Nothing, since an operational repo takes its changes direct to `develop`. - - **Detail** - Confirm the workflow really does trigger on `main` alone before editing, because a repo already naming both is conformant and needs no change. - - **Detail** - Leave the ruleset alone, since the required check stays on `main` and nothing is added to `repo-config/operational/develop.json`. - - **Detail** - The evidence this is not hypothetical is HomeAutomation-Config PR 34, which merged into `develop` with an empty check list and a clean mergeable state. - -- **Finish the host rollout and fill the tooling matrix, which are one visit each.** The rollout needs the matrix to be repeatable and the matrix is only worth filling if the rollout uses it. - - **Hub state** - Done for the documentary half, verified `develop` at `1ed0cc8` on 2026-08-03. - - **Outstanding** - Four machines, WSL2 Ubuntu, the MacBook Air and both ThinkPads, plus any headless or cron environment running with the token. macOS needs someone on that platform, the Proxmox question is whether that host also runs containers which decides whether Docker is required there, and the engine-inside-the-distro variant of the WSL2 Docker cell is unverified. - - **Detail** - The Windows half is a visit rather than a visit plus an unwritten script, since [`host-setup/windows/`][host-setup-windows] now carries the tooling and it was written and run on a Windows host. - - **Issue** - [#365][issue-365] and [#483][issue-483]. - - **Rides with** - Nothing on the hub, since the write-guard newline fix has landed on `develop` and a machine keeps running the old hook until the installer is re-run there. - - **Detail** - A ticked row means the host-wide rules text and not the hook, since only running the installer deploys both layers, and the proxmox host proved that distinction by carrying the documentary half alone for eight days on the machine where the incident originated. - - **Detail** - The prose comment batch rewrote comments in [`gh-write-guard.py`][write-guard] and both installer wrappers, so every installed copy is now behind the hub by that much. The divergence is comment-only and changes no decision the hook takes, which the self-test confirms, so it is a re-run of the installer at the next visit rather than a correctness problem. - - **Detail** - Honor the issue's own rule when filling a cell, that an unverified install command is worse than a blank, because a blank prompts a question while a wrong command produces a broken host and a false sense that setup succeeded. - - **Detail** - The superseded safety section from [#364][issue-364] still sits above the canonical block in this host's rules file, so the two overlap. Removing it is a judgment call on a per-machine file, which is why it is surfaced rather than applied. - -## Recorded for the Maintainer - -Actions on issues that are the maintainer's to take, each carrying its evidence so it is one action rather than a re-derivation. - -- **Re-scope [#305][issue-305] to the push half, and make it the tracking issue for the fleet re-vendor sweep.** Most of what it asked for is built, since the fidelity model, the [`spec/files.json`][files] manifest, [`spec/divergences.json`][divergences] with its generated [reports/divergences.md][divergences-report], and [`AUDIT.md`][audit-doc] section 10 together give the canonical-versus-adapted split and the audit path it proposed. What is genuinely still missing is the push half, since every one of those detects drift while the sweep that fixes it is manual. Re-scoped, it carries the "Fleet Sweeps" visit manifest and Blog as the pilot. Closing it against the built machinery is the alternative, and it loses the only tracking issue the sweep would have. -- **Decide which `install-tools.sh` failures are collected and which end the run.** [`apply_tool`][install-tools] collects a non-zero return from a tool's install function, so one failure does not strand the rest, and several install paths call `die` instead and end the whole run. Two kinds are mixed there. A refusal is deliberate and should stay fatal: an unverifiable keyring, a checksum mismatch, or a declined prompt each mean nobody vouched for what would be installed, and continuing past one is worse than stopping. An upstream lookup that cannot be answered is the case the collection exists for, and `node_install` failing to read the current long term support line, `uv_install` finding no build for the architecture, and `dotnet_install` finding no package for the distribution each end the run today, which is one unreachable upstream stranding every tool after it. Changing those three to return non-zero is a behavior change across the install path, so it wants its own verification on each supported distribution rather than riding along with a migration. Raised by review on [#667][pr-667], where the comment above `apply_tool` claimed the collecting behavior for all of them and now states the split. - -## Verified Complete, Awaiting Close - -Each was checked against the tree and has nothing left to do anywhere. Closing is the maintainer's call, and each wants the evidence quoted in the closing comment rather than a bare close. - -Nothing is awaiting close today. [#578][issue-578] was the last entry here and closed on 2026-08-08, and the part of it the fleet still owes is carried by the re-vendor entry under "Fleet Sweeps", which names the three sections it touches. - -<!-- Issues --> - -[issue-305]: https://github.com/ptr727/ProjectTemplate/issues/305 -[issue-310]: https://github.com/ptr727/ProjectTemplate/issues/310 -[issue-353]: https://github.com/ptr727/ProjectTemplate/issues/353 -[issue-363]: https://github.com/ptr727/ProjectTemplate/issues/363 -[issue-364]: https://github.com/ptr727/ProjectTemplate/issues/364 -[issue-365]: https://github.com/ptr727/ProjectTemplate/issues/365 -[issue-379]: https://github.com/ptr727/ProjectTemplate/issues/379 -[issue-456]: https://github.com/ptr727/ProjectTemplate/issues/456 -[issue-483]: https://github.com/ptr727/ProjectTemplate/issues/483 -[issue-489]: https://github.com/ptr727/ProjectTemplate/issues/489 -[issue-509]: https://github.com/ptr727/ProjectTemplate/issues/509 -[issue-521]: https://github.com/ptr727/ProjectTemplate/issues/521 -[issue-523]: https://github.com/ptr727/ProjectTemplate/issues/523 -[issue-550]: https://github.com/ptr727/ProjectTemplate/issues/550 -[issue-558]: https://github.com/ptr727/ProjectTemplate/issues/558 -[issue-577]: https://github.com/ptr727/ProjectTemplate/issues/577 -[issue-578]: https://github.com/ptr727/ProjectTemplate/issues/578 -[issue-580]: https://github.com/ptr727/ProjectTemplate/issues/580 -[issue-585]: https://github.com/ptr727/ProjectTemplate/issues/585 -[issue-597]: https://github.com/ptr727/ProjectTemplate/issues/597 -[issue-607]: https://github.com/ptr727/ProjectTemplate/issues/607 -[issue-623]: https://github.com/ptr727/ProjectTemplate/issues/623 -[issue-633]: https://github.com/ptr727/ProjectTemplate/issues/633 -[issue-639]: https://github.com/ptr727/ProjectTemplate/issues/639 -[issue-671]: https://github.com/ptr727/ProjectTemplate/issues/671 -[issue-672]: https://github.com/ptr727/ProjectTemplate/issues/672 -[issue-673]: https://github.com/ptr727/ProjectTemplate/issues/673 -[issue-729]: https://github.com/ptr727/ProjectTemplate/issues/729 - -<!-- Pull requests --> - -[pr-591]: https://github.com/ptr727/ProjectTemplate/pull/591 -[pr-620]: https://github.com/ptr727/ProjectTemplate/pull/620 -[pr-667]: https://github.com/ptr727/ProjectTemplate/pull/667 - -<!-- Upstream --> - -[copilot-review-schema]: https://github.com/orgs/community/discussions/204320 - -<!-- Repo --> - -[agents]: ./AGENTS.md -[audit]: ./spec/audit.py -[audit-doc]: ./AUDIT.md -[bootstrap]: ./host-setup/bootstrap.sh -[bootstrap-ps1]: ./host-setup/bootstrap.ps1 -[codestyle]: ./CODESTYLE.md -[copilot-instructions]: ./.github/copilot-instructions.md -[divergences]: ./spec/divergences.json -[divergences-report]: ./reports/divergences.md -[editorconfig]: ./.editorconfig -[fidelity-honesty]: ./spec/fidelity_honesty.py -[files]: ./spec/files.json -[fleet-map]: ./docs/fleet-map.md -[gitattributes]: ./.gitattributes -[governance]: ./GOVERNANCE.md -[host-setup-doc]: ./docs/host-setup.md -[host-setup-windows]: ./host-setup/windows/ -[install-tools]: ./host-setup/linux/install-tools.sh -[markdownlint]: ./.markdownlint-cli2.jsonc -[matrix]: ./reports/conformance-matrix.md -[operations]: ./OPERATIONS.md -[peer-messaging-doc]: ./docs/peer-messaging.md -[project-types]: ./spec/project-types.json -[prose-gate]: ./.github/actions/prose-gate/action.yml -[readme-sections]: ./spec/readme-sections.json -[readme-structure]: ./spec/readme-structure.md -[repo-gate]: ./scripts/repo_gate.py -[reports]: ./reports/ -[repos]: ./registry/repos.json -[reusable-workflows-doc]: ./docs/reusable-workflows.md -[scripts]: ./scripts/README.md -[secrets]: ./spec/secrets.json -[secrets-schema]: ./spec/secrets.schema.json -[section-model]: ./spec/section-model.md -[snippets]: ./catalog/snippets/ -[standup]: ./STANDUP.md -[third-party-tools]: ./spec/third-party-tools.json -[type-model]: ./spec/type-model.md -[validate-task]: ./.github/workflows/validate-task.yml -[workflow]: ./WORKFLOW.md -[workflow-reuse-report]: ./reports/workflow-reuse.md -[workflows]: ./catalog/snippets/workflows/ -[write-guard]: ./host-setup/agent-safety/gh-write-guard.py +# TODO + +Running backlog for this repo, kept in a committed file so the research survives across environments where agent memory does not. Entries are grouped by the change that ships them, so a `###` heading under "Work Clusters" is one pull request, and selecting work is reading the cluster headings rather than re-deriving the grouping from the entries. What each cluster touches and costs is a field on the cluster, since a cluster confined to one surface and a cluster spanning two are both legitimate and only the second needs saying. + +An entry carries `Blocked by`, `Issue` and `Checked` exactly once each, in that order, and never omits one, because an omitted field reads as unknown rather than as none. `Open` states a decision the session doing the work makes, and `Settled` states a finding that is not re-derived, each carrying a number, a proper name, or a rejected alternative. `Checked` is the freshness anchor, naming the branch, the commit, and the date a claim was last read against the tree, so a claim older than the branch is a claim rather than a finding. + +A cluster's `State` is one of four. `ready` means every open question is answerable by the session doing the work. `blocked` names the cluster it waits on. `decision` needs the maintainer. `measure` means the first action is a count rather than an edit. + +Adoption gaps for the skill-based fleet system are registered in [`docs/fleet-map.md`][fleet-map] rather than here, so the two files do not fork. A new observation about such a gap lands as a register row there, and this file carries only the pointer. + +## How to Select the Next Item + +The steps below are followed in order rather than sampled. + +1. Run `gh issue list --state open` and confirm every number it returns appears somewhere in this file. A number appearing nowhere is an entry that does not exist yet, so write it before selecting anything, because an invisible issue cannot be selected. Nothing mechanical enforces this, which is the honest limit of a hand-maintained file and the reason the step is first. +2. Run `gh pr list --state open` and confirm every pull request it returns carries a **stated active blocker**, written where the pull request itself carries it rather than held in a session that has ended. A blocker is active only while the thing it names is still true, so a review round that has landed, a dependency that has merged, and an outage that has passed each stop being one, and what they leave behind is a forgotten pull request rather than a parked one. The remedy is to finish it, close it, or write the current blocker down, and it happens before selecting new work rather than after, because the cost is not the waiting. A bot pull request is read rather than excluded, since nobody is there to write a blocker on one, so its blocker is whichever gate holds it open and is read off the pull request itself: an unfinished or failing check, a merge state of `BEHIND` because a sibling bot pull request merged first, or auto-merge disabled by a maintainer push, the last two of which [`GOVERNANCE.md`][governance] "Branching Model" documents as expected rather than as faults. One sitting open under none of them is the merge-bot having missed it, which is the finding rather than the exemption. [#591][pr-591] was parked correctly during a GitHub Actions outage and came back three days later twenty commits behind `develop`, conflicting in six regions, and carrying an exit code that had come to mean something else in the meantime. +3. Read the cluster headings and their `State` lines. A cluster is the unit of selection, so pick a cluster rather than an entry, and never carry two clusters in one pull request. +4. Prefer a cluster whose state is `ready`. Select a `decision` cluster only when the maintainer is present to answer its open questions, select a `blocked` cluster only after the cluster it names has shipped, and select a `measure` cluster knowing its deliverable is a number rather than a behavior change. +5. Re-verify every `Checked` line in the chosen cluster against current `develop` before writing anything, by reading the surface the anchor names rather than by re-reading the issue. An issue records the tree as it was on the day it was filed, so a claim in one is a starting point for a check rather than a finding to act on. +6. Rewrite the `Checked` line with the branch, the short commit, and the date whenever a claim is confirmed, whether or not the work ships in the same session. A re-verification that leaves no anchor is a check the next session repeats. +7. Move a claim the tree contradicts out of `Settled` and state what the tree carries instead. Where the tree answers a whole entry, move the entry to "Verified Complete, Awaiting Close" with the commit that answered it, and never delete it silently, since a deleted entry reads as work nobody recorded. +8. Fold a new observation in under one of four dispositions, named on the pull request carrying it: `New entry`, `Amends "<entry title>"`, `Already covered`, or `Already shipped as #N`. A second observation of a surface an entry already reasons about strengthens that entry rather than opening a second one. +9. An amendment adds a `Settled` bullet, shortens `Open`, and refreshes `Checked`. An observation that answers an open question deletes that question rather than annotating it. +10. Delete a cluster heading when its pull request merges, and move anything the pull request did not carry into a new cluster with its own state. + +## Work Clusters + +### Giving the Fleet's Own Pins Something to Resolve Against + +One pull request pointing a hub `uses:` at a hub-owned action, so that the resolvability pass added beside it has a reference under this owner to read. It is separated from that pass because it changes what a workflow runs, where the pass only changes what a gate reports. + +**State** `decision`. **Touches** the hub's own workflows. **Cost** one hub edit, hub-only, and it changes a running workflow so it is not a paper change. + +- **Decide whether the hub consumes its own [`prose-gate`][prose-gate] action the way the fleet does.** Today it calls `prose_lint.py` directly, so every `uses:` in the tree is under another owner. + - **Blocked by** - Nothing, though it is only worth doing on its own merits rather than to give a gate something to read. + - **Issue** - None filed. + - **Checked** - `develop` at `dbd1cdc` on 2026-08-07, where the tree carries 45 pinned `uses:` refs and not one of them names a `ptr727` repository. + - **Open** - Whether the hub gating itself through its own pinned action is desirable at all, given the action reads the rules from hub `develop` on a non-`main` target and the hub already has the script in its own checkout. + - **Settled** - The resolvability pass reports what it covered on every run, so the hub's zero is visible rather than silent, which is why this is a separate decision rather than a defect in that pass. + - **Settled** - The fleet's `ptr727` pins are live in the downstream repos that consume the action, and `repo_gate.py --root <repo>` from a hub checkout reads them there, so the pass is not idle fleet-wide. + +### The Declared Repository Description + +One pull request moving the canonical short description into declared data, so every check and every push reads a field rather than parsing a document, and the About panel gets something that writes it. + +**State** `decision`. **Touches** [`registry/repos.json`][repos] and its schema, [`spec/audit.py`][audit], and `repo-config/configure.sh`. **Cost** one hub edit, and repos adopt the field one at a time. The tagline rule this cluster once carried shipped on 2026-08-08. + +- **Declare the description in [`registry/repos.json`][repos] instead of deriving it by parsing the README.** Every check and every push then reads a field. + - **Blocked by** - Nothing. + - **Issue** - None filed, and the disposition is recorded on [#509][issue-509]. + - **Checked** - `develop` at `3d1a0b1` on 2026-08-06, where neither `registry/repos.json` nor `registry/repos.schema.json` carries a `description` key. + - **Open** - Nothing beyond sequencing, which is that this leads and the README shape follows. + - **Settled** - PhotoCleaner#32 measures the cost of parsing, since a workflow step reading the intro at publish time needs nine guards against headings, block quotes, all four list markers, ordered lists, HTML, tables, code, links and the length cap, and every one of them fails the release rather than the tagline. + - **Settled** - The field makes the README intro a third mirror rather than the source, so the audit compares all three against one declared value and `repo-config/configure.sh` sets the About panel from the same field it already sets every other setting from. + - **Settled** - The 100-character cap stays, since Docker Hub's short description is the tightest surface. + - **Settled** - The field is optional at first so the audit falls back to the README intro while repos adopt it, and it needs a schema entry because `registry/repos.schema.json` sets `additionalProperties: false`. + - **Settled** - The ask on the Docker repos meanwhile is only that the parsing step is not propagated further. + +- **Close the README-to-About hop, which is the only one nothing writes.** The audit reports a drifted About panel, and no tool sets it. + - **Blocked by** - The entry above, since the field is what `repo-config/configure.sh` would set the panel from. + - **Issue** - [#639][issue-639], filed on 2026-08-09 because this entry had been carrying [#577][issue-577], whose body covers only the README tagline and never mentions the About panel, and whose tagline half shipped on 2026-08-08. + - **Checked** - `develop` on 2026-08-08, where `repo-config/configure.sh` sets every other repository setting and carries no `description` handling, and [`catalog/snippets/workflows/publish-docker-readme-task.yml`][workflows] pushes `github.event.repository.description` to Docker Hub. + - **Open** - Nothing beyond sequencing. + - **Settled** - The chain is README, then the About panel by hand, then Docker Hub by CI, so the unautomated hop is the first one and it is the one that drifts. PhotoCleaner is the worked case, where the About panel still matched the README and only the Docker Hub short description had diverged. + - **Settled** - CI keeps reading `repository.description` rather than the README. Pointing it at the README puts a Markdown parser in a publish job, which PhotoCleaner#32 measured at nine guards, every one of which fails the release rather than the tagline. + - **Settled** - The tagline rule itself shipped on 2026-08-08 and is no longer owed here. The extraction rule this entry was once blocked on already existed: [`spec/audit.py`][audit] measured the first line for the About and Docker Hub mirrors all along, and narrowing the `HISTORY.md` mirror to match it was one line, so the sequencing that held the rule behind the registry field was stated more strongly than the code warranted. + +### Content in the Wrong File + +One pull request teaching the audit to see content sitting in a file the section model assigns elsewhere, which is invisible today and reported as a missing file instead. + +**State** `decision`. **Touches** [`spec/audit.py`][audit] and possibly [`spec/files.json`][files]. **Cost** one hub edit, hub-only, and it changes what every repo's next audit reports. + +- **Compare an `intent` file's headings against the destinations the section model assigns.** Collect the level-two headings, subtract the ones the manifest declares for that file, and compare the remainder against the headings other destinations declare. + - **Blocked by** - Nothing. + - **Issue** - [#523][issue-523], which carries the four things to settle. + - **Checked** - `develop` at `1ed0cc8` on 2026-08-03, where the audit checks file presence, declared-section presence, verbatim hashes, and workflow interface conformance, and nothing that reads a heading against a destination. + - **Open** - Whether an undeclared heading is a finding at all, given a repo may legitimately add locally. + - **Open** - Whether the destination mapping becomes declared data rather than prose, and whether it reaches the advisory `ARCHITECTURE.md`. + - **Open** - How many repos are affected, measured before the check is designed rather than after it starts reporting. + - **Settled** - The case that found it is a repo whose `.github/copilot-instructions.md` carried 311 lines under nine headings assigned to `ARCHITECTURE.md` and `OPERATIONS.md`, reported as a missing-file letter while the misplacement that caused it was invisible. + - **Settled** - The similarity-based version is rejected by [`spec/section-model.md`][section-model], and a detector built on it produces findings whose remedy is to delete content. + +### Registry Membership Coverage + +One pull request asking the inverse question the fleet tools never ask, whether a repository that exists has a registry entry, since every tool iterates the registry and an omission at standup is permanent and silent. + +**State** `decision`. **Touches** [`spec/audit.py`][audit], [`registry/repos.json`][repos] and its schema, and [`STANDUP.md`][standup]. **Cost** one hub edit, hub-only. + +- **Report a non-fork repository under the owner that has no registry entry.** The reports read as complete while under-counting today. + - **Blocked by** - Nothing. + - **Issue** - [#550][issue-550], which carries the four repos the comparison found. + - **Checked** - `develop` at `362aec8`, per the issue, and unverified since. + - **Open** - How a deliberate exclusion is recorded, since without one the check becomes a permanent four-line complaint people learn to scroll past, and the candidates are a third `status` value or a separate list carrying a reason per entry. + - **Open** - Where the check runs, since neither `validate.py` in CI nor an owner-initiated audit catches an omission at the moment it is made, which is the standup itself and the moment the fix costs one line. + - **Settled** - The consequence is worse than a gap, because the reports are confidently wrong rather than silent: [reports/divergences.md][divergences-report] counted 19 repos owing `AGENTS.md` "Fleet Bootstrap" when the real number was 20. + - **Settled** - The procedure is not the gap, since [`STANDUP.md`][standup] section 1A already says to write the entry and names every field, and nothing verifies it happened. + - **Settled** - The reason matters more than the mechanism, since an unexplained exclusion is the same silent omission in a different file. + - **Settled** - Private repositories are outside the public listing the issue used, so the true count is a floor rather than a total. + +### Reducing the Carried Surface Further + +One pull request measuring the remaining carried surface against the carry-versus-reach test and moving whatever qualifies, now that the model is settled rather than open. + +**State** `decision`. **Touches** [`AUDIT.md`][audit-doc], [`spec/secrets.json`][secrets], and [`spec/files.json`][files]. **Cost** one hub edit plus a retirement per repo on its next visit. The workflow half of this cluster, replacing copy-pasted workflow content with cross-repo reuse, is measured and answered under "Hub-Hosted Reusable Workflows" below. + +- **Measure carried [`AUDIT.md`][audit-doc] and [`spec/secrets.json`][secrets] against the test.** Each is adapted per repo today and the question is how much of each is genuinely per-repo. + - **Blocked by** - Nothing. + - **Issue** - None filed, and [#305][issue-305] covers the propagation half from the other direction. + - **Checked** - `develop` at `3d1a0b1` on 2026-08-06, where [`spec/files.json`][files] declares both at `intent` and no longer declares `repo-config/configure.sh` at all. + - **Open** - Which of the two moves, if either. + - **Settled** - The test is stated: a repository carries the content it is audited against and the configuration that describes it, and it reaches machinery whose content is identical in every repository. + - **Settled** - `repo-config/configure.sh` is the first file moved across, carrying the ledger's only `retire` disposition and naming six repos, NxWitness, aiopurpleair, homeassistant-purpleair, ESPHome-NonRoot, VSCode-Server-DotNetCore and LanguageTags. + - **Settled** - An unreachable hub means the tool did not run, reported as not run rather than worked around, since a hand-rolled substitute is the duplicated effort the model exists to end. + +### The README Structure Rework + +The spec rework and its audit check shipped. What remains is the per-repo conformance the check now reports, and one section the fleet carries that the model does not name. + +**State** `decision`, on where `## Build Artifacts` belongs, which is the only thing here a hub pull request settles. The four conformance entries above it are not selectable as hub work at all: each lands on a repo's own next visit, in the sense "Fleet Sweeps" below gives that phrase, and they sit here rather than there because the finding counts are what the shipped check measures. **Touches** each repo's `README.md` on its next visit, plus [`spec/readme-structure.md`][readme-structure] and [`spec/readme-sections.json`][readme-sections] if `Build Artifacts` is adopted. **Cost** one edit per repo, driven by the finding rather than by a sweep. + +- **Work off the conformance backlog the `readme-structure` dimension now reports.** Measured across all 22 cataloged repos on 2026-08-08, against the shipped checks: 73 findings, 71 on sections and 2 on shields, plus the 3 retired-badge findings the entry below carries. + - **Blocked by** - Nothing, and no repo is edited by the hub. Each lands on its own next visit. + - **Issue** - None filed. + - **Checked** - Every repo's default branch on 2026-08-08, with the hub read at its own `develop`. + - **Settled** - The shape of the work: 17 repos owe `3rd Party Tools`, 10 owe the `Overview` rename, 9 owe a Table of Contents, 7 owe a License section, and 5 public repos owe `Questions or Issues`. + - **Settled** - Three order findings are genuine and each is one move: LanguageTags places Installation after Usage, aiopurpleair places Getting Started after Installation, and PlexCleaner places Questions or Issues immediately after the Table of Contents where the order now puts it ninth. + - **Settled** - Two placement findings are genuine: KiCadLibrary carries a `## TODO` after `## License`, which the "TODO.md" rule already forbids, and HomeAutomation-Config renders the license shield twice, once outside the License section. + - **Settled** - MediaTools carries a `NuGet Pre-Release` shield that renders the same version as its `NuGet Release` shield, and it is dropped on that repo's next visit. The check does not report it, because a shield class is a floor and an extra shield is never a finding. + - **Settled** - Blog is the only repo carrying a `3rd Party Tools` table today, and it needs both fixes the rule now states: drop the License column, which the audit reports since 2026-08-09, and rewrite all three roles, since "theme, vendored under `themes/`" and "web server, serving the built site and the redirects" describe this repo's wiring where "static site generator" describes the tool correctly and differs only by its opening capital and its full stop. + +- **Bring each repo's `3rd Party Tools` entries onto the shared catalog.** Measured on 2026-08-09: 57 findings across four repos, a link, a description, or an ordering that disagrees with [`spec/third-party-tools.json`][third-party-tools], plus the one License column [`spec/readme-structure.md`][readme-structure] forbids. + - **Blocked by** - Nothing. + - **Issue** - None filed. + - **Checked** - Every repo's default branch on 2026-08-09, with the hub read at its own `develop`, which now conforms. + - **Settled** - The bulk is absent descriptions rather than wrong ones: 48 of the 57 are a tool listed with no description at all, across LanguageTags, MediaTools and PlexCleaner, and PlexCleaner alone accounts for 25 of those and 27 findings overall. Of the remaining nine, three describe a tool differently from the catalog, four link it differently, one is Blog listing Hugo, PaperMod, Caddy out of alphabetical order, and one is Blog's License column. + - **Settled** - Twelve tools already appear in more than one repo, which is what makes the catalog worth having before the 17 repos owing the section write their own wording for each. + - **Settled** - Four tools are already linked by two different URLs across the fleet, and the catalog picks one each: GitHub Actions takes `github.com/actions`, Dependabot takes `github.com/dependabot`, Nerdbank.GitVersioning takes the project repo rather than its marketplace action, and uv takes `docs.astral.sh/uv/` to match ruff. The hub was the outlier on the first two and is fixed. + - **Settled** - PlexCleaner lists Bring Your Own Badge as a tool, so the retired badge service has a fourth touchpoint beyond the three rendering it, and that entry goes with the same deletion. + - **Settled** - The catalog is a standard set and not a complete one, so a tool only one repo uses is unaudited. Of the 36 tools the fleet lists today, 24 are used by exactly one repo and are declared only so the second adopter copies rather than invents. + +- **Work off the reference-link naming and grouping backlog.** Measured across all 22 repos on 2026-08-08: 55 letter findings on naming and 27 drift findings on grouping. + - **Blocked by** - Nothing, and each repo's block is one edit. + - **Issue** - None filed. + - **Checked** - Every repo's default branch on 2026-08-08, with the hub read at its own `develop`, which now conforms. + - **Settled** - The naming half was already the fleet's practice before it was written down: 119 of 122 shield references end `-shield` and 514 of 532 URI references end `-link`, and `actions-link`, `releases-link`, `issues-link` and `discussions-link` are unanimous across every repo carrying them. + - **Settled** - The two real naming inconsistencies are the repository root, which 10 of 20 call `github-link` and the rest name for the project, and `./LICENSE`, which 9 repos call `license-link` where a repo-local path is a bare reference. + - **Settled** - The grouping half is drift rather than letter because it is not met: the fleet carries seventeen distinct group-header names, and two repos, NxWitness with 116 definitions and ESPHome-NonRoot with 45, carry no group headers at all. + - **Settled** - KiCadLibrary is the largest single block at 22 naming findings, almost all of them repo-local paths named `-link`. + +- **Delete the retired `byob.yarr.is` last-build badge from the three repos still carrying it.** The service is deprecated and the badge is not required by any shield class, so the fix is a deletion rather than a replacement. + - **Blocked by** - Nothing, and each repo's fix is deleting one shield line and one reference definition. + - **Issue** - None filed. + - **Checked** - Each repo's default branch on 2026-08-08, with the endpoints requested the same day: MediaTools and KiCadLibrary both return **HTTP 404**, so they already render a broken badge, and ESPHome-NonRoot still returns 200. + - **Settled** - The audit reports it, so this does not rely on anyone remembering: `deprecatedShields` in [`spec/readme-sections.json`][readme-sections] carries the retired service and the check fires on exactly those three repos. + - **Settled** - A dead badge is worse than an absent one, because it renders broken rather than missing and a visitor cannot tell a retired service from a failing build. + - **Settled** - All three repos are already non-conformant on other grounds, so this rides their next visit rather than earning a pass of its own. + +- **Decide where `## Build Artifacts` belongs.** LanguageTags and aiopurpleair both carry it, opening with the same `**Build process and artifacts**:` line and covering package, versioning, and publishing. + - **Blocked by** - Nothing. + - **Issue** - None filed. + - **Checked** - Both repos' default branches on 2026-08-08, where the section is the only one recurring across repos that [`spec/readme-sections.json`][readme-sections] does not name. + - **Open** - Whether it becomes a named optional section, folds into `Build and Distribution`, or moves to [`WORKFLOW.md`][workflow], since its content overlaps both. + - **Settled** - It is not a finding today. An unnamed heading is dropped before the order comparison, so the two repos carrying it pass, which is why this is a decision rather than a defect. + +### Two Project Types and a Shared C++ Style + +One pull request extending the type model with the two types the fleet already needs, plus the shared style the `cpp` type has no canonical for. + +**State** `ready`. **Touches** [`spec/project-types.json`][project-types], [`catalog/snippets/`][snippets], [`CODESTYLE.md`][codestyle]. **Cost** one hub edit plus a carried `CODESTYLE.md` re-vendor. + +- **Add a linter-only Python type for codegen and boilerplate Python.** Code that runs during another tool's build to emit generated source ships no unit tests and no coverage and needs only the linter. + - **Blocked by** - Nothing. + - **Issue** - None filed. + - **Checked** - `develop` at `1ed0cc8` on 2026-08-03. + - **Open** - Nothing. + - **Settled** - It stays distinct from the existing `python` type, which is utility code that can and should carry unit tests and coverage, as in PlexCleaner. + - **Settled** - ESPHome-Config stays `source-only` until it exists and its reclassification is deferred, so its one outstanding validation finding is accepted meanwhile. + +- **Add a fleet-standard clang-format config for the `cpp` type.** A catalog snippet plus a `CODESTYLE.md` C++ section, the analogue of the shared ruff config. + - **Blocked by** - Nothing. + - **Issue** - None filed. + - **Checked** - `develop` at `1ed0cc8` on 2026-08-03. + - **Open** - Nothing. + - **Settled** - It exists so the `cpp` clang-format check references one canonical style rather than each repo inventing its own, and the ESPHome-Config agent's proposed file is the base. + +### How a Hugo Repository Carries Its Theme + +One pull request deciding what the `hugo` type says about a theme, which it declares nothing about today. + +**State** `decision`. **Touches** [`spec/project-types.json`][project-types] and [`spec/type-model.md`][type-model]. **Cost** one hub edit, and it becomes the type's contract that a second generator inherits. + +- **Decide the theme carry mechanism as a question about the type rather than about Blog.** The candidates differ along the same axis the carried-content clusters are about. + - **Blocked by** - Nothing. + - **Issue** - None filed, and [#456][issue-456] and [#558][issue-558] carry the type's intake. + - **Checked** - `develop` at `b82c1a3` on 2026-08-05. + - **Open** - Which of three the type requires, the vendored copy Blog ships, a submodule pinned to an upstream ref, or a separate fleet-owned repository the site consumes. + - **Settled** - A vendored theme is a copy that goes stale with nothing detecting it, and a submodule is a pin Dependabot can see, which is the whole difference. + - **Settled** - Three details the intake predicted are wrong against what Blog runs, so planning from the prediction encodes requirements the repo does not meet: the theme is vendored with no recorded upstream ref rather than a Dependabot-tracked submodule, the generator is pinned by version and hash rather than run at latest, and the deploy is a separate dispatch rather than a tag cut last after the live check. + - **Settled** - What held is that the deploy is a publish, the type is named for the generator with the generic checks phrased so they do not name it, and the URL parity gate asserting a floor on the golden list length before comparing is the check of record. + - **Settled** - Promoting the generator-agnostic `hugo` checks to a shared type when a second generator arrives is a registry edit by construction, per [`spec/type-model.md`][type-model] "Generators". + - **Settled** - The `copilot_code_review` rule in both ruleset payloads gates no merge today, because gated Copilot review is an invite-only beta, which deserves a sentence near the merge gate so no repo reads the rule as the enforcement and relaxes the manual discipline holding the line. + +### Locally Required Secrets + +One pull request giving a repo a declared way to say what it needs at runtime, the way GitHub-stored secrets are already declared. + +**State** `decision`. **Touches** [`spec/secrets.json`][secrets] and its schema, [`spec/audit.py`][audit], and the hub's own `.gitignore`. **Cost** one hub edit plus adoption per repo that deploys. + +- **Make a gitignored secrets directory the fleet standard and declare its contents.** The required set is discoverable only by reading the deploy today. + - **Blocked by** - Nothing. + - **Issue** - None filed. + - **Checked** - `develop` at `1ed0cc8` on 2026-08-03, where [`spec/secrets.json`][secrets] covers only the Actions and Dependabot stores and the hub carries neither the directory nor a `.gitignore` entry for one. + - **Open** - Nothing on the local half, and the GitHub half below is the same axis rather than a separate problem. + - **Settled** - The pattern already runs in the fleet in two shapes, HomeAutomation-Config keeping a gitignored secrets directory of env files and Docker secret files, and ESPHome-Config keeping a gitignored `secrets.yaml` beside a committed `_secrets.yaml`. + - **Settled** - The committed file carries the required names with dummy values, so the shape of the requirement is in git while the values never are, which is the split the GitHub side already gets from `requiredSecrets`. + - **Settled** - Blog needs it immediately, since it deploys on the proxmox host through HomeAutomation-Config's Docker Compose stack and carries the copy destinations and the internal URI. + - **Settled** - Adopting it in the hub comes first, since the hub carries neither piece. + - **Settled** - The GitHub side has the same missing axis, surfaced by the `hugo` type, since a deploy's credentials are per-environment secrets and variables while `stores` is a closed enum of `actions` and `dependabot`, and [`spec/audit.py`][audit] seeds its map with those two keys and indexes it unguarded, so adding an `environments` value raises a key error for every repo whose publish maps to that mechanism. + - **Settled** - An optional `environments` block is legal in [`spec/secrets.schema.json`][secrets-schema] so a repo may declare its per-environment names, and no tool reads one where it exists, which is honest and is not a gate, so a clean audit says nothing about whether an environment is configured. + +### The Docker Image Freshness Rule + +One pull request stating that an agent never assumes a Docker image is present locally, however recently it pulled one. + +**State** `ready`. **Touches** [`GOVERNANCE.md`][governance], and [`OPERATIONS.md`][operations] if the mirrored one-liners move with it. **Cost** one hub edit, plus a carried re-vendor if the rule lands in a carried section. + +- **State the always-pull default and the explicit pull where the flag does not apply.** A background prune can remove an image between two commands of the same session. + - **Blocked by** - Nothing. + - **Issue** - None filed. + - **Checked** - `develop` at `1ed0cc8` on 2026-08-03, where the four documented lint invocations already carry the always-pull flag and no rule states why. + - **Open** - Where it lives, since "Running the Linters Locally" is scoped to the four lint tools while the rule covers any container an agent starts, and whether it is carried, since every repo runs the same images from the same instructions. + - **Settled** - What is missing is the rule rather than the one-liners, since an agent composing an ad-hoc `docker run` drops the flag precisely because it believes the image is cached. + - **Settled** - The honest limit stops the flag reading as the whole answer, since `docker run` against a registry tag re-pulls an absent image on its own, so the cases that break are a locally built tag with no registry to pull from, and any command that branches on the image being present such as `docker image inspect` or `docker images`. + +### Hub-Hosted Reusable Workflows + +One pull request per stage moving a standard workflow out of every repo and into the hub as a `workflow_call` task, with a downstream caller stub and a composite-action hook for what is genuinely repo-specific. The design, the hook contract, the pin policy and the staged rollout are in [`docs/reusable-workflows.md`][reusable-workflows-doc], and the burn-down is [`reports/workflow-reuse.md`][workflow-reuse-report], regenerated by `python3 spec/workflow_reuse.py --report`. The merge-bot task shipped with the design and its adoption is a sweep, so this cluster starts at the gates. The stage-by-stage completion state, one checkbox per hub change and per adopting repo with the evidence that closed it, is that doc's "Rollout" section, and a session resumes from there rather than from here. + +**State** `ready` for the gates, `blocked` on the gates for everything after. **Touches** the hub's `.github/workflows/`, [`spec/files.json`][files], [`catalog/snippets/workflows/`][workflows], and [`WORKFLOW.md`][workflow] where a guarantee names a copied job. **Cost** one hub edit per stage plus an adoption per repo on its next visit, and no re-vendor beyond the stub each stage introduces. + +- **Host the gates: `validate-task.yml` with a `validate` hook, and `test-pull-request-task.yml` with the fixed aggregator.** The hub owns the per-type doc-lint block once, the hook carries a repo's own tests, and the stub carries the trigger shape, operational or release. This stage is where the hook fallback is first proven live, on the hub for the default and on a pilot for the override. + - **Blocked by** - Nothing. + - **Issue** - None filed. [#585][issue-585] and [#729][issue-729] are settled inside this stage, the first by the operational stub's trigger and the second by the one place the hub validate task pins or floats its `uvx` tools. + - **Checked** - `develop` at `7c67328` on 2026-08-15, where the report counts 20 copies of `test-pull-request.yml` in 13 variants and 13 copies of `validate-task.yml` in 11, and the doc-lint block (markdownlint, cspell, actionlint, editorconfig-checker) repeats in every one. + - **Open** - Whether the per-type lint steps are selected by an input the stub sets or read from the repo's registry entry through a hub checkout at `github.job_workflow_sha`, since the second needs no per-repo input and the first needs no network read. + - **Open** - Whether a `validate` hook that runs a domain compile (an ESPHome build, a KiCad ERC) is one hook or several, given the two repos carrying such a step run it as a separate job today. + - **Settled** - Pilots are HomeAutomation-Config, the smallest operational tree, then a C# repo, so both trigger shapes are exercised before the sweep. + - **Settled** - The step gated on `hashFiles('.github/actions/validate/action.yml') != ''` runs the caller's hook from its own checkout, else the default from a hub checkout under `.hub/`, and a local composite action resolves at step time from the workspace, which is what makes the fallback expressible at all. + +- **Host the pure functions: `get-version-task.yml` and `publish-plan-task.yml`.** Neither has a repo-specific line, and the plan job is missing where D4.1 needs it. + - **Blocked by** - The gates, only for sequencing, since a repo adopts one stub per visit and the gates come first. + - **Issue** - None filed. + - **Checked** - `develop` at `7c67328` on 2026-08-15, where 5 of 8 `get-version-task.yml` copies are identical and all 3 `publish-plan-task.yml` copies are. + - **Open** - Nothing. + - **Settled** - PlexCleaner carries no `plan` job, so its next scheduled run ships a Dependabot bump, and the hub-hosted plan job is the fix rather than a per-repo copy. + +- **Host the release chain: `build-release-task.yml` with `build-<target>` hooks, `publish-release-task.yml`, and the Docker core.** The orchestration is generic and the target list is per repo, which the hooks express without a per-repo copy of the orchestrator. + - **Blocked by** - The pure functions, since the release task calls both. + - **Issue** - None filed. + - **Checked** - `develop` at `7c67328` on 2026-08-15, where the report counts 10 copies of `build-release-task.yml` in 6 variants, 17 of `publish-release.yml` in 12, and 5 of `build-docker-task.yml` in 4, with the Docker core identical in every copy. + - **Open** - The `matrix` input shape for a multi-image Docker repo, and whether a base-image build is a hook or a second task the stub calls first. + - **Settled** - The three no-asset release shapes the fleet runs today collapse into `expect_release_assets`, and PhotoCleaner and PlexCleaner pilot, then the NuGet, PyPI and Docker-only repos. + - **Settled** - Vanilla Docker repos need only `image` and build-args, ESPHome-NonRoot adds a `docker-prepare` hook for its upstream pin, and NxWitness adds the matrix hook and `build-base`, in that order. + +- **Host the type-specific tasks: Docker Hub readme, upstream-version tracking, deploy-site, codegen, and the date badge.** Each with its hook, and the two string-command inputs the catalog carries today (`transform-run`, `resolver-command`) become hooks. + - **Blocked by** - The release chain, since the readme task replaces the in-job description push. + - **Issue** - None filed. + - **Checked** - `develop` at `7c67328` on 2026-08-15, where each of these has one or two carriers. + - **Open** - Whether ESPHome-NonRoot's second tracker, whose bump waits for a human, is the same task with `auto-merge: false` or stays repo-local. + - **Settled** - The `operational-vs-release-workflow` skill's note that a target-agnostic target list is "intentionally not done" is retired by this stage rather than before it, since it is true until then. + +- **Decide the three merge-bot inputs the design leaves open.** The `delete-branch` default, the Dependabot semver-major filter two repos carry, and a `requiredHubUses` audit contract. + - **Blocked by** - Nothing, and each is a maintainer call rather than a finding. + - **Issue** - None filed. + - **Checked** - `develop` at `7c67328` on 2026-08-15, against the 16 downstream copies read for the design. + - **Open** - All three, stated in [`docs/reusable-workflows.md`][reusable-workflows-doc] "Open Decisions". + - **Settled** - Neither blocks adoption: `delete-branch: false` is the hub's behavior and seven repos opt in, and the semver filter is a D8.1 conformance question for the two repos that carry it. + +- **Bring the Docker repos onto one multi-stage Dockerfile shape.** The build stages are inconsistent across the five Docker repos, and that is Dockerfile content rather than workflow content, so it rides beside the workflow migration rather than inside it. + - **Blocked by** - Nothing. + - **Issue** - None filed. + - **Checked** - Not measured. Raised by the maintainer on 2026-08-15 while reviewing the Docker family design, and the first action is a read of the five Dockerfiles. + - **Open** - Whether the shape is a `CODESTYLE.md` section, a `docker` type check, or both. + +### Review Cost and the Local Review Pass + +One pull request, after a measurement, stating what change size licenses and whether a local adversarial pass earns its place, which are one question because both are about where review cost goes. + +**State** `measure`. **Touches** [`GOVERNANCE.md`][governance] branching or review guidance, once the numbers exist. **Cost** a measurement first, then one hub edit plus a carried re-vendor. + +- **Measure review rounds against pull request size, and decide what the number licenses.** The data needs no new instrumentation, since the review history carries it. + - **Blocked by** - Nothing. + - **Issue** - None filed. + - **Checked** - `develop` at `1ed0cc8` on 2026-08-03. + - **Open** - The threshold, expressed as the size at which a change is split rather than as advice to keep changes small. + - **Settled** - For each recent pull request the record carries the diff size in files and lines, the number of rounds, and the findings per round, counting suppressed findings alongside threaded ones because they are the majority of what these loops produce. + - **Settled** - Two confounds bound any line drawn from the numbers, that a large change is usually also a novel one so size and unfamiliarity move together, and that a round finding something new is the reviewer working rather than evidence of a problem, so the metric is findings a smaller first cut would have surfaced earlier. + +- **Try local defensive-review subagents as a first pass, and measure what the pass is worth.** One agent per lens rather than one general reviewer. + - **Blocked by** - Nothing. + - **Issue** - None filed. + - **Checked** - `develop` at `b82c1a3` on 2026-08-05. + - **Open** - Whether the overlap is large enough to shorten the remote loop rather than to add a step in front of it, which is what running both for a stretch measures. + - **Settled** - The remote loop is where most of a session's tokens and wall-clock go, and it delivers findings one round at a time, which is the slowest available way to learn that a change had five problems. + - **Settled** - The trap is that a local pass finding nothing reads exactly like a clean change, and the next inference is that the remote review can be skipped, which is the one outcome the review contract exists to prevent, so the local pass is an input to the loop and never a substitute for the round the merge gate requires. + +### Where a Disproof Goes When the Reviewer Is Not Copilot + +One pull request routing the disproof record from the provider-agnostic contract, so an agent that never opens the provider runbook still knows where a proof lives after the thread closes. + +**State** `ready`. **Touches** [`GOVERNANCE.md`][governance] "PR Review Etiquette". **Cost** one hub edit plus a carried re-vendor of a byte-locked section, which is why it is not folded into the change that built the record. + +- **State that a disproof is recorded where it survives the pull request, not only in the thread.** The record exists in [`.github/copilot-instructions.md`][copilot-instructions] "Disproved Claims" and nothing agent-agnostic points at it. + - **Blocked by** - Nothing. + - **Issue** - None filed. + - **Checked** - `develop` at `756a53e` on 2026-08-07, where outcome 2 of "Every Finding Ends in an Action" ends at the thread, "Responding and Resolution Expectations" requires the proof and says nothing about where it then lives, and the only pointer to the runbook is scoped to provider mechanics. + - **Open** - Whether the destination is named in the byte-locked text at all, since a repository is free to keep its record elsewhere and a rule naming one file is a rule that has to be true in every copy. + - **Settled** - The write side is where the gap bites rather than the read side, because an agent following the loop is already routed to the runbook for mechanics and an agent posting a decline is routed nowhere. + - **Settled** - "Durable Knowledge and Self-Improvement" already requires durable knowledge to reach a committed file, so this states where one class of it goes rather than adding an obligation. + +### A Programmatic Reading of a Copilot Review + +One pull request, after a survey, deciding whether anything stands between this fleet's review loop and the raw prose of a Copilot review. Today `scripts/pr_review.py` reads the review body as text and holds a vetted inventory of the headings, collapsed sections, metadata labels and coverage wordings it recognizes, blocking on anything it does not. That design is correct for a prose surface and it carries a cost the maintainer has accepted deliberately: a wording change at GitHub blocks every open pull request in the fleet at once, until the inventory is updated. The cost is worth paying against a reviewer silently missing a raised finding, which is the failure it replaces, but it is worth paying only for as long as prose is the only surface on offer. + +**State** `measure`. **Touches** `scripts/pr_review.py` and the runbook section in [`.github/copilot-instructions.md`][copilot-instructions], once the survey says whether there is anything to move to. **Cost** a survey first, then either nothing or a rewrite of the reading layer, which is the larger of the two outcomes and the reason the survey comes first. + +- **Find out whether GitHub publishes a structured form of a Copilot review, and decide whether to read that instead of the prose.** A schema, an API surface, a published payload, or a maintained library, anything that would make a wording change a non-event rather than a fleet-wide block. + - **Blocked by** - Nothing. The prose reader ships either way, so this decides what replaces it rather than whether the loop has a gate. + - **Issue** - None filed here, and the ask is filed upstream as [GitHub community discussion 204320][copilot-review-schema], which asks for a versioned machine-readable schema carrying severity, category, suggestion and resolution state, rather than the human-facing prose an integration has to infer those from. It is unanswered, so it is a place to watch rather than a dependency to wait on. The prose reader and its vetted inventory shipped under [#607][issue-607], which is the change this would supersede. + - **Checked** - `develop` at `20916ad` on 2026-08-07, reading the live GraphQL schema by introspection and one review over REST, against the reader in `scripts/pr_review.py`. + - **Open** - Whether `bodyHTML` is a better surface than the Markdown body, since it arrives as a rendered tree whose structure survives a change in Markdown syntax, while leaving the wording drift the inventory exists for exactly where it is. + - **Open** - Whether any third-party library tracks this output, and whether depending on one is acceptable at all, given that [`scripts/README.md`][scripts] holds these scripts to the standard library with no third-party packages. + - **Open** - Whether the review's own inline threads and their metadata carry enough to derive coverage and suppression without reading the body, which would narrow the prose surface rather than replace it. + - **Settled** - The public API carries no structured Copilot review as of the date above. GraphQL `PullRequestReview` exposes `body`, `bodyText` and `bodyHTML` and no field naming a finding, a file count, or a withheld section, and REST returns the same prose body beside its ids and its state. + - **Settled** - The only Copilot-named types in the GraphQL schema are `CopilotCodeReviewParameters` and its input form, which configure review-on-push inside a branch ruleset and describe nothing about a review that has run, so the schema search that looks promising by name answers a different question. + - **Settled** - A negative finding is the deliverable as much as a positive one, and it is recorded here rather than re-derived, since the reading layer's design rests on prose being the only surface and that premise is worth re-checking rather than assuming. + +- **Find out which file a partial round skips, and why re-requesting never clears it.** The coverage reading shipped in #608 blocks on a partial round, and the record says the state is durable rather than transient. + - **Blocked by** - Nothing, though it is research rather than a change, and the reader already reports the state correctly. + - **Issue** - [#623][issue-623], filed from a downstream repository against the `PARTIAL` caveat's claim that the reviewer names no file list. The reading that surfaces the state shipped under [#607][issue-607]. + - **Checked** - `develop` at `674a27a` on 2026-08-08, measured over 348 Copilot review bodies on the newest 120 pull requests here and 121 on the fleet's Blog repository, each read against the pull request's own changed-file list rather than against its counts alone. + - **Settled** - The reviewer does name a file list, and the caveat saying otherwise was wrong. It is a `| File | Description |` table carried by 91 of the 348 bodies, and every table row in the corpus belongs to one of those tables. + - **Settled** - The table names the unread file on exactly one round of the seven, which states 16 of 17 and names 16, omitting `GOVERNANCE.md`. That round is also the only evidence on record that the unread file is a real file rather than an artifact of counting. + - **Settled** - It cannot be read as coverage anywhere else. It names the whole changed set on partial and fully covered rounds alike, including all seven partials on Blog, while one round here states 61 of 62 and names 50, another states 33 of 33 and names 32, and a third names `GOVENANCE.md`, the reviewer's own spelling and a path no diff carries. A reading identical under both outcomes discriminates neither. + - **Settled** - Three of the four partials here carry their table on the round before a push, describing the diff that push replaced, so the comparison is head-scoped like the counts and reports no table rather than a stale list of unreviewed files. + - **Open** - Whether a partial round is worth escalating to GitHub at all. One named file on one round is a starting point rather than the pattern an escalation needs. + - **Settled** - It is durable rather than flaky. Four pull requests and seven rounds (#476, #479, #592, and the #609 promotion), and **every later round repeated the identical ratio**. A re-request has never cleared one, so the remedy the digest first stated was wrong and now says so. + - **Settled** - Size does not predict it. The partials changed 502, 629 and 961 lines, while fully covered pull requests here reach 33 files and 2,219 lines. + - **Settled** - The reviewer counts the file and does not read it, rather than losing it earlier. The stated denominator equals the API's own `changedFiles` on **103 of 104** pull requests, the exception being one whose branch shrank between rounds. + - **Settled** - Splitting remains a real remedy for a feature branch and is unavailable for a promotion, whose head is `develop`, so a promotion carrying a partial round is a maintainer decision by construction. + +### A Resolve the Loop Cannot Perform and a Thread Nobody Can Find + +The review loop ends by replying on a thread and resolving it, and both halves failed on one pull request in ways the runbook describes nowhere. The resolve mutation was refused by the agent harness's own permission layer before any request left the machine, seconds after the reply mutation carrying the identical thread id had succeeded, so the refusal was neither GitHub's nor the id's. Handing the resolve to the maintainer then failed a second time, because the digest names a thread by its `PRRT_` node id, that id appears nowhere in the GitHub interface, and the person asked to resolve it could not find what to click. + +**State** `ready`. **Touches** `scripts/pr_review.py`, the runbook section in [`.github/copilot-instructions.md`][copilot-instructions], and [`OPERATIONS.md`][operations]. **Cost** one pull request, since the query change is one field and the runbook change is one paragraph. + +- **Carry a thread's own web address beside its node id, so a resolve can be handed to a person.** `Q_THREADS` selects `id`, `isResolved`, `path`, `line` and the first comment's `author` and `body`, and not its `url`, so the digest can name a thread and cannot point at it. Selecting `url` and printing it beside the id makes the hand-off one click. + - **Blocked by** - Nothing. + - **Checked** - `develop` at `0e4a1c2` on 2026-08-08, reading `Q_THREADS` in `scripts/pr_review.py` against the digest line that consumes it. + - **Detail** - The two identifiers are not interchangeable and neither is derivable from the other without a query. A `PRRT_` node id is what a mutation takes, and a `#discussion_r` fragment is what the web page anchors on. + - **Detail** - The evidence is [#620][pr-620], where a thread was handed over by node id and the reply was that it could not be found. + +- **Give the runbook a shape for a write the harness refuses, which it currently has none for.** Its list of dead paths is entirely GitHub's own refusals, a silent no-op, a 422, and the wrong bot login for the API in use, so a local refusal matches none of them and reads as a bad identifier, which invites the retry a blocked write must never get. + - **Blocked by** - Nothing. + - **Checked** - `develop` at `0e4a1c2` on 2026-08-08, against the known-non-working-paths list in the runbook. + - **Detail** - The distinguishing evidence is that a reply on the same thread id, in the same session, had already succeeded and returned a comment url, so the identifier was demonstrably good. + - **Detail** - What cleared it was a permalink and a human click, and the durable remedy is a permission rule in host settings. That is host state rather than repo content, so it belongs in the runbook as a note rather than in a committed configuration file. + +- **Confirm a resolve by re-reading the thread rather than by the mutation returning.** `reply` already exits 63 where the resolve did not report the thread resolved, which is the right shape, and a loop driving `gh api` by hand gets no exit code at all and so cannot notice. The rule worth writing down is that the state is the evidence. + - **Blocked by** - Nothing. + - **Checked** - `develop` at `0e4a1c2` on 2026-08-08, reading the exit-code table in the `scripts/pr_review.py` module docstring. + - **Detail** - This is the failure the suppressed-findings count already exists for, where a step that stopped running reads exactly like a step that passed. + +### Watching a Downstream Pull Request Touch Hub-Owned Content + +One pull request adding the observer the fleet has no equivalent of, reading merged pull requests across the fleet and resolving every changed path against what the hub declares it owns. The tools today read standing state, so a divergence is visible only once it is already there, and a repo-local file the manifest never names is invisible at every stage. + +**State** `ready`. **Touches** a new `spec/carry_watch.py` with its self-test, [`reports/`][reports], [`AUDIT.md`][audit-doc], and [`.github/workflows/validate-task.yml`][validate-task]. **Cost** one hub script, hub-only, plus a first run whose output is a triage backlog rather than a change. + +- **Read merged fleet pull requests and classify each changed path against the manifest.** The gap is a whole reading rather than a missing field, since nothing anywhere enumerates pull requests. + - **Blocked by** - Nothing. + - **Issue** - None filed. [#633][issue-633] is the instance that prompted it, raised by a downstream agent after the maintainer noticed it editing hub-managed CI files, and nothing mechanical had reported that. + - **Checked** - `develop` at `c2ce145` on 2026-08-08, reading [`spec/files.json`][files], [`spec/divergences.json`][divergences] and [`registry/repos.json`][repos], and running the enumeration query live against the owner. + - **Open** - How a window wider than a thousand results is split, since the GitHub search API caps there and a silent truncation is the false clean this whole class of tool exists against. The split has to be visible in the output rather than inferred. + - **Open** - Whether a `SECTION` or `CONTRACT` classification reads content in the same pass or defers to a human, since the path alone says a carried file moved and not which region of it. + - **Settled** - The enumeration is one query rather than a per-repo loop, measured live: `search(query: "org:ptr727 is:pr is:merged base:main merged:>=<DATE>", type: ISSUE)` returned 112 pull requests over a fortnight with per-pull-request `files` and `repository` inline. The `base:` term comes from each repo's registry `groundTruthBranch` rather than a hardcoded `main`. + - **Settled** - It belongs in `spec/` beside [`spec/fidelity_honesty.py`][fidelity-honesty], which is its sibling in every respect that decides placement, being owner-initiated, absent from CI, an importer of `audit` as a library, and a writer of a generated report. [`scripts/`][scripts] holds gates run against one repo named by `--root`. + - **Settled** - The classes are `OVERSTEP` for a `verbatim` whole unit, `SECTION` for a path declaring verbatim sections, `CONTRACT` for an `interface` unit, `GAP` for a path the hub tracks that the manifest never names, and `CANDIDATE` for a path absent from the hub changed in a pull request that also touched one of the others. + - **Settled** - `intent` and `presence` units are deliberately not watched, being downstream-owned by design, and that exclusion is what makes suppression keyed on the ledger correct rather than over-broad. + - **Settled** - `GAP` plus `CANDIDATE` is the pair that means a downstream wired local tooling into a workflow the hub authored, which is exactly `ptr727/Blog#69`: it changed `.gitattributes`, `.github/workflows/validate-task.yml` which is a ledger `gaps` entry dispositioned `investigate`, and a `checks/check-eol-pins.py` the hub has never heard of. A sibling pull request shows the same shape over a whole `checks/` tree. + - **Settled** - Triage needs no new store. [`spec/divergences.json`][divergences] already carries `upstream-candidate` in its disposition vocabulary, meaning the downstream carries an improvement the hub should adopt, and nothing in the ledger uses it today. A dispositioned pair prints with its disposition and everything else renders `UNTRIAGED`, which is what [`reports/divergences.md`][divergences-report] already does. + - **Settled** - The rule goes in [`AUDIT.md`][audit-doc] section 9 rather than [`GOVERNANCE.md`][governance], whose sections are verbatim fleet law, so an edit there puts every downstream repo into verbatim drift until re-vendored for a sentence that is procedure rather than law. + - **Settled** - Two floors are not optional. A run reading zero pull requests reports that rather than a clean zero, and a repo the search surfaces with no registry entry is reported separately, which feeds "Registry Membership Coverage" above. + - **Settled** - This is not the deferred audit automation recorded under "Standalone Chores". That entry rejected three scheduled and hook-driven shapes on three blockers, and this is owner-run and on demand like [`spec/fidelity_honesty.py`][fidelity-honesty], so it lands on none of them. + +### A Disproof About Carried Text Has Nowhere Fleet-Wide to Live + +One pull request deciding whether the review record admits a second class of entry, for a finding raised against text the whole fleet carries rather than against one repository's own file. The record's shape and its per-repository rule are both right and neither covers this case, so the deliverable is a decision about the category rather than a rewrite of what exists. + +**State** `decision`. **Touches** [`.github/copilot-instructions.md`][copilot-instructions], and possibly [`spec/section-model.md`][section-model] if the answer is a new carried section. **Cost** one hub edit, plus a fleet-wide re-vendor only if the entries themselves become carried. + +- **Decide where a disproof about carried text lives, given that every repository carrying the text will meet the same finding.** The record's preamble says the entries are the hub's own, that a repository carrying the file keeps the shape and the rules rather than the findings, and that it records what it has proved itself. That is correct for a finding about one repository's tree and wrong for one about a canonical every repository holds a copy of. + - **Blocked by** - Nothing. + - **Issue** - None filed. + - **Checked** - `develop` at `7bc6978` on 2026-08-10, against the `keys_unsorted` entry at `.github/copilot-instructions.md` and the preamble sentence beginning "The entries are this repository's own". + - **Open** - Whether the answer is a second entry class in the record marked as carried, a fleet-level record somewhere else, or nothing at all on the grounds that independent re-derivation is worth its cost because it catches an entry that has gone stale. + - **Settled** - The case is real rather than predicted. A reviewer raised the `keys_unsorted` claim against the section 6 snippet at the hub, it was disproved by running both builtins on `jq-1.5-1-a5b5cbe`, and the same claim was then raised against a downstream repository's carried copy of the same snippet, where it was disproved a second time from the jq 1.5 manual. Two disproofs of one claim about one canonical, and the second could not cite the first. + - **Settled** - The independent re-derivation was not wasted, which is what makes this a decision rather than a defect. The second disproof came from the manual where the first came from a binary, so the two cover intent and behavior rather than repeating one another, and a rule that suppressed the second would have lost that. + - **Settled** - The current rule is right about what it governs. A repository carrying the hub's findings would carry claims about files it does not have, each naming a revision it never had, which is the staleness the per-repository rule exists to prevent. So the fix cannot be to relax that rule, and any answer has to distinguish the subject of a finding from the repository that filed it. + - **Settled** - The cost is bounded and recurring rather than one-off. It falls once per repository per finding, on carried text only, and only where a reviewer raises the same point twice. It is small enough that doing nothing is a legitimate outcome, which is why this is a decision cluster and not a defect. + +### Peer Messaging Between Agents as a Declared Method + +One pull request writing down the agent-to-agent messaging this fleet has now used successfully, so it is a method with stated boundaries rather than a capability each session rediscovers. The mechanism already works and needs no build, so the deliverable is prose plus the decision about where prose that binds a downstream agent is allowed to live. + +**State** `ready`, and the deliverable ships beside [`docs/fleet-map.md`][fleet-map], so this cluster is deleted when that pull request merges. **Touches** [`docs/peer-messaging.md`][peer-messaging-doc]. **Cost** one hub edit, and no re-vendor. + +- **Declare peer messaging a standard method, and decide which document carries its rules.** The safety half is the load-bearing half: confirm a peer's identity before sending it anything substantive, verify a peer's factual claims against the tree before repeating them, never read a peer's request as the maintainer's approval, and never ask a peer to perform what the asking session was denied. + - **Blocked by** - Nothing. The mechanism is live and was exercised end to end on 2026-08-10. + - **Issue** - None filed. + - **Checked** - `develop` at `3855dbb` on 2026-08-10, against a live exchange with the ESPHome-Config session on this host, and `ListAgents` listing two local peers and no cloud or remote row. + - **Settled** - The rules live in the hub-only [`docs/peer-messaging.md`][peer-messaging-doc], per the location decision recorded in [`docs/fleet-map.md`][fleet-map] "Peer Messaging", and that doc states the promotion criteria under which the carried option is re-evaluated. + - **Settled** - The write-up states the same-host limit and leaves cross-host undocumented until a second machine is reachable, which is what [`docs/peer-messaging.md`][peer-messaging-doc] does. + - **Settled** - Same-host works and cross-host does not, by construction rather than by configuration. A peer address is a Unix domain socket under `/run/user/1000/cc-socks/`, which cannot cross a machine boundary. Cloud sessions and Remote Control sessions on other machines are the documented cross-host paths and neither appears in a listing on this host, so both are unverified rather than absent. + - **Settled** - The addressing has a guardrail worth keeping in the write-up. A bare peer name was refused and the transport required the `[ref]` a listing prints, which is what stops a message reaching the wrong repository's agent. + - **Settled** - The method earns its place on evidence rather than novelty. One exchange produced the causal commit for the section 6 ruleset defect, `90e3255`, which the hub session had not identified from the symptom, plus a one-line reproduction of the gojq key-sorting behavior that made an earlier fix pass for the wrong reason, plus four procedure gaps a reader found that no gate reports. + - **Settled** - A peer's finding is checked rather than adopted. Two of those four did not reproduce at the hub, the `AGENTS.md` anchor rewrite and the settings-diff exposure, and one did and shipped as #653. So the write-up states verification as a step rather than as a courtesy. + - **Settled** - The boundary that matters most is not politeness but permission. A peer cannot widen what the asking session may do, so work blocked in one session goes back to the maintainer rather than sideways to another agent. + +### What Building the Windows Host Tooling Surfaced + +Three findings raised while writing [`host-setup/windows/`][host-setup-windows], each about the Linux side or the fleet rather than about the new scripts, and none blocking them. + +**State** `ready` for the first two, `decision` for the third. **Touches** [`docs/host-setup.md`][host-setup-doc], and the three scripts under `host-setup/linux/`. **Cost** one hub edit each, and no re-vendor, since nothing under `host-setup/` is carried. + +- **Record why the host tooling carries no linter category, or decide that it should.** No installer on either platform manages `markdownlint`, `cspell`, `actionlint`, `editorconfig-checker`, `shellcheck`, `PSScriptAnalyzer` or `ruff`, and nothing states that as a decision, so the absence is correct and reachable only by inference. + - **Blocked by** - Nothing. + - **Issue** - [#671][issue-671]. + - **Checked** - `main` at `1d5b076` on 2026-08-11, where `readonly TOOLS=(git gh jq git-restore-mtime node python uv dotnet)` names no linter and no comment says why. + - **Settled** - The reasoning holds and is worth writing down once rather than per platform: each linter runs as a pinned image or through `uvx`, so the image tag fixes the version and a local run matches CI, and installing native copies would put a second unpinned version on the host and break exactly that. + - **Open** - Whether it belongs in [`docs/host-setup.md`][host-setup-doc] as a fleet fact, which is what covers Linux by the same sentence, or stays per platform where only the Windows README states it today. + +- **Report the `gh` git protocol in `setup-github.sh`, as its Windows peer does.** A host can pass every check the fleet runs while `gh` is configured for https, and a checkout made through `gh` then authenticates by token where every other checkout on that host authenticates by key. + - **Blocked by** - Nothing. + - **Issue** - [#672][issue-672]. + - **Checked** - Measured on the maintainer's Windows host on 2026-08-11, where `gh auth status` reports `Git operations protocol: https` against `gh` 2.97.0, an SSH key that signs and verifies, and `scripts/host_gate.py` exiting 0 over all seven declared tools. + - **Settled** - Reported rather than written, since rewriting a working authentication configuration is the operator's call, and `setup-github.sh` touches `gh` nowhere today. + - **Open** - Whether `--configure` should set it, which is the only part where the two platforms could still diverge. + +- **Align the Linux scripts onto "name one action" instead of "the last one given wins".** Overwriting `MODE` in the arg loop discards an intent silently, and it discards it in the dangerous direction: `--report --install` drops the safe action and keeps the one that changes the host. + - **Blocked by** - Nothing. + - **Issue** - [#673][issue-673]. + - **Checked** - `main` at `1d5b076` on 2026-08-11, where all three scripts document last-wins, no documented example passes two actions, `bootstrap.sh` passes exactly one per `run_tool` call, and no test asserts the behavior. + - **Settled** - The Windows tooling already refuses this way. That began as a constraint, since a PowerShell `param()` block records which switches were given and not their order, and the constraint produced the better behavior. + - **Open** - Nothing about the change itself, which is three `usage()` heredocs and three `parse_args()` bodies. The decision is only whether the fleet wants the stricter contract, and taking it deletes the differences-table row in [`host-setup/windows/README.md`][host-setup-windows] rather than leaving a permanent divergence. + +### Neither Host Bootstrap Has Run Against a Truly Fresh Host + +Two loaders exist so a copy-paste snippet takes a stock OS install to a configured dev host, and neither has ever been run that way. Everything either has behind it is a dry run or a read against an already-configured checkout, on a machine carrying most of the target tools already. That confirms the logic is internally consistent. It confirms nothing about a `winget` package id still resolving, a stock Debian netinst actually lacking `curl` the way the docs assume, `tar.exe` genuinely shipping on a given Windows image, or the interactive menu reading correctly on a real console. This needs a human watching a real run on a real fresh image and reporting back what broke, including anything that merely looked fine, since neither of those closes from a description of the logic. + +**State** `ready`. **Touches** [`host-setup/bootstrap.sh`][bootstrap] and [`host-setup/bootstrap.ps1`][bootstrap-ps1] and, if either run turns something up, whichever script under [`host-setup/linux/`][install-tools] or [`host-setup/windows/`][host-setup-windows] it drives. **Cost** VM time on the images each entry names, and an iteration round trip per finding, since a fix this file cannot verify is a fix that needs the same fresh image again. + +- **Run `bootstrap.sh` unattended against a fresh Debian and a fresh Ubuntu image, and again to confirm the second run is idempotent.** `--host --yes` finishing clean, with nothing to fix, is the signal. A re-run reporting no further changes confirms idempotency rather than assuming it. + - **Blocked by** - VM access to a current image of each, and one still-supported older release per distribution, since the contract's floors are meant to hold there too. + - **Issue** - None filed. + - **Checked** - `develop` at `82a87d3` on 2026-08-13, where this loader has existed since #674 and carries no record of a run against an image with nothing preinstalled. + - **Open** - Which images, who runs the pass, and whether a failure blocks the loader or is filed and worked separately, since a fresh-host pass can turn up findings well past what one pull request should carry. + +- **Run `bootstrap.ps1` unattended against a fresh Windows 10 image with no App Installer, and a fresh Windows 11 image, then again on each to confirm idempotency.** The Windows 10 case is the one that exercises the winget-missing remedy this loader prints but has never had checked against a real console. Windows 11 is the expected common case, App Installer and `winget` both present. `-Host -Yes` finishing clean on each, then a clean re-run, is the same signal as the Linux entry above. + - **Blocked by** - VM access to both images. + - **Issue** - None filed. + - **Checked** - Branch `feature/windows-bootstrap-loader` on 2026-08-13, adding this loader for the first time. It has run under `-DryRun` and against `PSScriptAnalyzer` on a dev machine that already carries `pwsh`, `winget`, and most managed tools, which is signal on the script's internal consistency and none at all on whether it survives a host it has not touched. + - **Open** - Same as the Linux entry: which images, who runs the pass, and how a finding routes back. + +## Standalone Chores + +Small work with no research to preserve, selectable one bullet at a time. + +- **Answer the symmetric reading of [`.editorconfig`][editorconfig], a path-specific section naming files that do not exist**, which is the half of [#633][issue-633] the `eol-coverage` check deliberately left open. The dead-pin reading it does ship is the `.gitattributes` side, and the same question on the other document is not the same shape: this repo's `[.github/workflows/*]` and `[catalog/snippets/workflows/*]` sections are legitimately broad, and the issue's own first attempt at it produced false positives because the matcher did not expand brace syntax, which [`scripts/repo_gate.py`][repo-gate] already implements. Measure the exemption against the live corpus before building the gate rather than after, since a stale exemption hands out a work list that damages correct documents, and decide whether `forward-declared` carries across or whether an editorconfig section needs its own marker. +- **Reconsider whether the pre-commit hook runs the doc gates now that they are diff-scoped.** [`scripts/README.md`][scripts] records the current decision and its reason, that doc linters stay out of the hook so it stays fast, which was sound when the only mode was a whole-tree sweep, and a diff-scoped run finishes in about a second. The failure it would prevent is the most repeated one on record, comment sentences wrapped across lines caught after the commit rather than before it. Weigh it against the standing preference for a fast hook. The other objection, a hook running the gate from the wrong directory and reporting its own false clean, no longer applies: the rule set, the file set, the diff, and the keys joining them are all read from the repository being scanned rather than from wherever the process stands. +- **Audit the fleet's shell surface by size and branching, and decide per script whether Python with unit tests is cheaper.** The evidence is the review record rather than a language preference, since a non-trivial shell script earns findings round after round while every gate under [`scripts/`][scripts] carries a test file under `scripts/tests/` and converges in one or two. The measure is lines, branch count, and the review rounds each has cost. `repo-config/configure.sh` and the agent-safety installer are the two worth measuring, and a bootstrap script that needs the Python it exists to install is not a rewrite worth having, which protects the installer more than the config script. +- **Make a table of contents standard for a long document rather than for the README alone.** [`spec/readme-structure.md`][readme-structure] fixes one at README position 4 and no other hub file carries one, which leaves the three longest documents without it, `CODESTYLE.md` at 516 lines, `GOVERNANCE.md` at 436 and `WORKFLOW.md` at 301, measured on `develop` at `3d1a0b1` on 2026-08-06. Settle the threshold in headings or lines so the audit can check it, and settle how it sits with the reference-link exception, since the four agent-instruction files keep inline links exactly because they are read one section at a time, which is the property that makes a contents list worth having in them. The mechanical constraint is that the list is filled by the Markdown All in One extension on save, so a file nobody opens in the editor grows a stale list, which is worse than absent because it is read as current. +- **Converge this repo's Python on the ruff configuration it already declares, then add the formatting half to the pre-commit hook.** `pyproject.toml` carries `[tool.ruff]` and [`spec/project-types.json`][project-types] declares `python.ruff.config`, yet no workflow runs ruff and the tree does not pass it, measured on `develop` at `6d020b1` on 2026-08-09 with ruff 0.16.2: `ruff format --check` reports 13 of 57 files would be reformatted, and `ruff check` reports 106 errors, of which 39 are auto-fixable. The largest groups are 24 `PLW1510` (a `subprocess.run` with no `check`), 17 `FURB167` (`re.M` for `re.MULTILINE`), 11 `EXE001` (a shebang on a non-executable file, which wants reading against the `eol-coverage` shebang set rather than fixed blindly), 9 `BLE001` and 9 `SIM117`. The hook deliberately ships without the ruff step for this reason, since a gate failing on the corpus it guards blocks every commit from the moment it lands, which is the measure-the-corpus-first rule applied to a gate rather than to an exemption. Decide whether CI gains a ruff job in the same pass, since a formatter enforced only by a hook is enforced only on the machines that enabled it. +- **Adopt the OCI annotation keys for Docker image metadata across the Docker repos**, replacing the ad-hoc and label-schema keys, per [#363][issue-363]. +- **Sweep the central package-version property to `Directory.Packages.props` fleet-wide**, since PlexCleaner sets it in `Directory.Build.props`, off the [`CODESTYLE.md`][codestyle] canonical. +- **Canonicalize Python linter-config placement on `pyproject.toml`**, since one cataloged repo uses a standalone ruff config plus a pyright config. Track it as a drift finding and fix it downstream. +- **Populate [reports/][reports] for the cataloged repos that still have no audit**, since a registry `status` of `cataloged` asserts a result only a committed report evidences. Nine of 22 have one, measured on `develop` at `3d1a0b1` on 2026-08-06. This is paced by maintainer capacity rather than blocked, since repos are brought up to spec as they are worked on. +- **Finish onboarding hardening**, from [#310][issue-310], making the [`AUDIT.md`][audit-doc] audit a required onboarding step and running the per-type cold-start self-tests tracked in [reports/conformance-matrix.md][matrix]. Every cold-standup cell reads not-tested today. +- **Decide whether the human entry points the README now carries belong in [`spec/readme-structure.md`][readme-structure]**, so a fleet repo is measured on them rather than reinventing them. The README routes by reader (browsing, adopting, blocked by a rule, reporting, an agent) in an optional Getting Started table, and answers adoption, divergence, and issue-reporting in the Installation, Configuration, and Questions or Issues slots the spec already orders. What is undecided is how much of that is fleet-general, since a repo shipping an application has a different reader set from a rules hub, and a per-section index was considered and declined because it trades brevity for a sync obligation to whatever the docs contain. This sits beside "The README Structure Rework" and is settled with it rather than before it. +- **Consider renaming this repo to reflect the audit-catalog identity**, which updates badge and link URLs across the fleet. +- **Revisit automating the audit**, explored and deliberately deferred, recorded so the reasoning is not re-derived. Three shapes were considered, a scheduled hub-driven audit publishing each report as a workflow artifact, the same thing committing the report back, and a pull-request hook in each downstream repo auditing itself against the current hub. Three things block all of them: until the fleet reaches stasis a scheduled run reports mostly noise, since a repo mid-onboarding is expected to be non-conformant, the hub has to be stable before downstreams audit against it because a hub change lands as fleet-wide findings the same day, and the downstream half is a catch-22 since a self-auditing hook is CI instrumentation the repos that most need it do not carry. Worth reopening once the fleet is onboarded and the hub goes a stretch without carried-content changes, and the artifact shape is the one to try first since it produces evidence without committing anything. + +## Fleet Sweeps + +Work that lands on a downstream visit rather than as a hub pull request, so it is not selectable here. The fleet is caught up periodically rather than after every hub change, which means a carried-content edit landing in the hub does not owe an immediate sweep and this list is expected to carry several entries at once. + +Blog is the pilot. A sweep is proven there before any fleet-wide rollout, because it is the smallest tree, `hugo` plus `source-only` with no build to break, cataloged and audited on 2026-08-05, and one of only two repos carrying `AGENTS.md` "Fleet Bootstrap" today, so a carried-section change can be observed arriving there. The other carrier is HomeAutomation-Config, which is `operational` and therefore exercises the direct-to-`develop` path rather than the pull request one, which is the second visit worth making rather than the first. + +Regenerate [reports/divergences.md][divergences-report] before using it as the work list, since it is a live pass over each repo's ground-truth branch and the committed copy is only as current as its last run. A stale ledger is the same hazard as a stale exemption, in that it hands out a work list measured against a tree that no longer exists. The reason this line used to give, that the committed copy still rendered `repo-config/configure.sh` under a re-vendor disposition, did not survive the check: that copy already carried the `retire` disposition, so the warning was true of the decision rather than of the file. What the 2026-08-09 regeneration actually moved was three rows, adding `AGENTS.md` "Fleet Bootstrap" as divergent at Blog and HomeAutomation-Config, and widening `GOVERNANCE.md` "Verification Discipline" and "Workflow YAML Conventions" from one repo to four. + +- **Re-vendor the changed `verbatim` content, which is one sweep covering seven files.** Every repo holding a copy of a changed section is byte-mismatched against the hub until it takes the new one, which the audit reports as stale rather than modified. + - **Hub state** - Done, verified `develop` at `3d1a0b1` on 2026-08-06 for the sections below, with the prose batch adding five more [`GOVERNANCE.md`][governance] sections, verified `develop` at `d791930` on 2026-08-07. + - **Outstanding** - The whole fleet, pilot on Blog first. + - **Issue** - None filed, and it is the follow-through [#489][issue-489] and [#379][issue-379] wait on. + - **Rides with** - The `configure.sh` retirement and the `.editorconfig` line from [#353][issue-353], since all three are the same visit. + - **Detail** - In [`AGENTS.md`][agents], "Context and Delegation Discipline" carries the wait rule's failure clause and "Where the Rules Live" carries a row for "Hub-Hosted Tooling". + - **Detail** - In [`GOVERNANCE.md`][governance], "Verification Discipline" carries the rule that a launched process is not a result and the rule that a change's checks are located before any is run, with CI's coverage not being that list, "PR Review Etiquette" carries the five outcomes that close a finding, "Repository Boundaries and Write Safety" carries the rule that a refused write is reported rather than re-shaped, and both "Representative Data in Agent-Authored Text" and "Hub-Hosted Tooling" are entirely new carried sections no downstream repo holds, which the audit reports as sections that never arrived rather than as drift. + - **Detail** - Three further [`GOVERNANCE.md`][governance] sections differ by a single word each, "Documentation Style Conventions", "Communicating with the User" and "Repository Details", where a format name took the capitalization [`CODESTYLE.md`][codestyle] "Markdown and Spelling" states, so they are byte-mismatched for a reason a reader of the diff would otherwise call cosmetic. + - **Detail** - Two comment lines in [`.markdownlint-cli2.jsonc`][markdownlint] took the same capitalization, and that file is `verbatim` and `whole`, so every downstream copy is byte-mismatched on a config nothing else changed about. + - **Detail** - [`CODESTYLE.md`][codestyle] is the fifth file, at `intent` rather than `verbatim`, so it reaches the fleet as a rule each repo adopts in its own copy, and the same mixed spelling waits in every downstream tree. + - **Detail** - [`.github/copilot-instructions.md`][copilot-instructions] is the sixth, also at `intent`, where "Reply and Thread Resolution Workflow" now leads with the hub's reply helper and keeps the hand-run mutations as the cross-owner and unreachable-hub path. A repo taking the old copy is not broken by it, since the mutations it documents still work, so this rides the visit rather than gating it. + - **Detail** - The same file's "Triggering and Polling" reads the reviewer bot's node id across the repo's newest pull requests rather than from the pull request under review, because the id is the reviewer account's own and is identical on every pull request in the repo, read as one value across all eight of the newest here on 2026-08-08. This is the other part that propagates a procedure rather than refreshing a hash, so a repo left on the old copy reads its own runbook as requiring a review on the pull request before the id can be read, and hands round 1 to the maintainer to seed through the UI whenever auto-review-on-open does not fire, which is the hand-off the mutation exists to remove. + - **Detail** - The prose batch rewrote punctuation in five `verbatim` [`GOVERNANCE.md`][governance] sections, "Branching Model", "Release Model", "Documentation Style Conventions", "PR Review Etiquette" and "Workflow YAML Conventions", so every downstream copy of those five is byte-mismatched and the audit reports it as stale. No rule changed meaning, so the re-vendor is a hash refresh rather than a propagation, and a repo taking the old copy is correct on the rule while wrong on the bytes. + - **Detail** - The [#578][issue-578] widening is one of the two parts of this sweep that propagate a rule rather than refreshing a hash, the runbook correction above being the other, so a repo left on the old copy is wrong on the rule and not merely on the bytes, which makes the pair the half to carry first. It touches three `verbatim` [`GOVERNANCE.md`][governance] sections, "Branching Model", "Communicating with the User" and "Operational Repositories", and the third of those matters most on the two `operational` repos that can act on it. [`WORKFLOW.md`][workflow] took a cross-reference in the same change and is `intent`, so nothing reports it. + - **Detail** - [`WORKFLOW.md`][workflow] is the seventh file and `repo-config/README.md` joins [`CODESTYLE.md`][codestyle] and [`.github/copilot-instructions.md`][copilot-instructions] at `intent`, where a punctuation-only edit produces no hash and therefore no audit finding at all. Nothing reports these, which is why they are recorded here rather than left to the run. `HISTORY.md` is `presence` and is each repo's own changelog, so its one fix owes nothing downstream. + +- **Adopt the merge-bot caller stub, which is one file per repo replacing the copied job bodies.** The audit reports the missing `merge-bot` caller job on every copy until the repo adopts, which is the work list. + - **Hub state** - Done on `develop`, where `.github/workflows/merge-bot-task.yml` is the task and the hub's own `merge-bot-pull-request.yml` is the stub. The stub a repo copies is in [`docs/reusable-workflows.md`][reusable-workflows-doc] "Adopting the Merge-Bot", and its pin is the first hub release carrying the task, so no repo can adopt before that release. + - **Outstanding** - Every repo carrying the file, 16 today, pilot on HomeAutomation-Config, then homeassistant-purpleair for the `rules` input. + - **Issue** - [#521][issue-521], whose hub half is done and whose sweep half this is. + - **Rides with** - The `verbatim` re-vendor above. + - **Detail** - The unused `GITHUB_TOKEN` grants #521 names are gone with the copy, since the task declares none and the stub sets `permissions: {}`. + - **Detail** - Two repos filter Dependabot by ecosystem and semver tier, and per D8.1 the filter drops on adoption unless the open decision in the cluster above lands first. + - **Detail** - The pilot records what the hub cannot prove, cross-repository resolution of the pin, the first Dependabot bump of it, and the `rules` input end to end, in its own audit report. + +- **Carry the `Local Verification` heading into every repository's `OPERATIONS.md`.** The heading leads the file and states what verifying a change there requires, naming the part of the repo's contract CI structurally cannot exercise, and a repo whose gates are entirely in CI says that under it rather than omitting it. + - **Hub state** - Done, verified `develop` at `8e10a2c` on 2026-08-06, where [`spec/section-model.md`][section-model] and [`STANDUP.md`][standup] declare six headings and this repo's own [`OPERATIONS.md`][operations] leads with the section. + - **Outstanding** - Every repo carrying an `OPERATIONS.md`, which is every repo, since none holds the heading yet. + - **Issue** - [#597][issue-597], filed from a downstream repo whose pre-merge gate sat under a heading of its own invention and was skipped by an agent following every carried rule correctly. + - **Rides with** - The `verbatim` re-vendor above, since the carried [`GOVERNANCE.md`][governance] rule that points at the heading lands in the same visit and neither half works alone. + - **Detail** - The audit reports nothing here today, because `OPERATIONS.md` is presence-checked only, so a repo using none of the declared headings passes. The heading check is [#523][issue-523]'s cluster, "Content in the Wrong File", and until it ships this sweep is verified by reading each file rather than by a run. + - **Detail** - A repo that already documents a local gate has the content and not the location, so the visit is usually a re-heading rather than new prose, and the prose it does need is the sentence naming what CI cannot reach. + +- **Retire the downstream `repo-config/configure.sh` copies.** Delete the copy as each repo is next worked on and run the hub's script against it by name. + - **Hub state** - Done, verified `develop` at `3d1a0b1` on 2026-08-06, where [`spec/files.json`][files] no longer declares the file and [`spec/divergences.json`][divergences] carries it under the `retire` disposition. + - **Outstanding** - Six repos, NxWitness, aiopurpleair, homeassistant-purpleair, ESPHome-NonRoot, VSCode-Server-DotNetCore and LanguageTags. + - **Issue** - None filed, and [#580][issue-580] carries the decision. + - **Rides with** - The `verbatim` re-vendor above. + - **Detail** - Nothing asks a repo for the file and nothing reports its absence, which makes this a visit-ordered chore rather than a gate. + - **Detail** - The six carried a fork predating the payload-driven check mode, which is the drift this removes rather than converges. + +- **Drop the `.editorconfig` analyzer relaxation across six C# repos.** The hub side is done and the tree confirms it. + - **Hub state** - Done, verified `develop` at `3d1a0b1` on 2026-08-06, where the analyzer severity property appears nowhere in `.editorconfig`. + - **Outstanding** - Six C# repos, sequenced in the issue so PhotoCleaner's 362 sites do not gate the other five. + - **Issue** - [#353][issue-353], which stays open on the downstream half alone. + - **Rides with** - The `verbatim` re-vendor above. + +- **Close out the two downstream acknowledgements that hold their issues open.** Neither is hub work. + - **Hub state** - Done, verified `develop` at `1ed0cc8` on 2026-08-03, where the manifest gap [#379][issue-379] raised is closed by `repo-config/settings.json` reaching [`spec/files.json`][files], and the `configure.sh` half has since been retired outright. + - **Outstanding** - Financial-Modeling's acknowledgement and re-vendor for [#379][issue-379], and the re-vendor [#489][issue-489] leaves. + - **Issue** - [#379][issue-379] and [#489][issue-489]. + - **Rides with** - The `verbatim` re-vendor above. + +- **Widen the operational lint trigger to `develop` on four repos.** Each triggers on a pull request to `main` only and therefore runs nothing at all on a pull request into `develop`. + - **Hub state** - Done, verified `develop` at `b82c1a3` on 2026-08-05, where the change is prose and spec, so it fixes no downstream repo by itself. + - **Outstanding** - Four repos, HomeAutomation-Config, ESPHome-Config, HomeAssistant-Config and Vantage-Config, one line each. + - **Issue** - [#585][issue-585]. + - **Rides with** - Nothing, since an operational repo takes its changes direct to `develop`. + - **Detail** - Confirm the workflow really does trigger on `main` alone before editing, because a repo already naming both is conformant and needs no change. + - **Detail** - Leave the ruleset alone, since the required check stays on `main` and nothing is added to `repo-config/operational/develop.json`. + - **Detail** - The evidence this is not hypothetical is HomeAutomation-Config PR 34, which merged into `develop` with an empty check list and a clean mergeable state. + +- **Finish the host rollout and fill the tooling matrix, which are one visit each.** The rollout needs the matrix to be repeatable and the matrix is only worth filling if the rollout uses it. + - **Hub state** - Done for the documentary half, verified `develop` at `1ed0cc8` on 2026-08-03. + - **Outstanding** - Four machines, WSL2 Ubuntu, the MacBook Air and both ThinkPads, plus any headless or cron environment running with the token. macOS needs someone on that platform, the Proxmox question is whether that host also runs containers which decides whether Docker is required there, and the engine-inside-the-distro variant of the WSL2 Docker cell is unverified. + - **Detail** - The Windows half is a visit rather than a visit plus an unwritten script, since [`host-setup/windows/`][host-setup-windows] now carries the tooling and it was written and run on a Windows host. + - **Issue** - [#365][issue-365] and [#483][issue-483]. + - **Rides with** - Nothing on the hub, since the write-guard newline fix has landed on `develop` and a machine keeps running the old hook until the installer is re-run there. + - **Detail** - A ticked row means the host-wide rules text and not the hook, since only running the installer deploys both layers, and the proxmox host proved that distinction by carrying the documentary half alone for eight days on the machine where the incident originated. + - **Detail** - The prose comment batch rewrote comments in [`gh-write-guard.py`][write-guard] and both installer wrappers, so every installed copy is now behind the hub by that much. The divergence is comment-only and changes no decision the hook takes, which the self-test confirms, so it is a re-run of the installer at the next visit rather than a correctness problem. + - **Detail** - Honor the issue's own rule when filling a cell, that an unverified install command is worse than a blank, because a blank prompts a question while a wrong command produces a broken host and a false sense that setup succeeded. + - **Detail** - The superseded safety section from [#364][issue-364] still sits above the canonical block in this host's rules file, so the two overlap. Removing it is a judgment call on a per-machine file, which is why it is surfaced rather than applied. + +## Recorded for the Maintainer + +Actions on issues that are the maintainer's to take, each carrying its evidence so it is one action rather than a re-derivation. + +- **Re-scope [#305][issue-305] to the push half, and make it the tracking issue for the fleet re-vendor sweep.** Most of what it asked for is built, since the fidelity model, the [`spec/files.json`][files] manifest, [`spec/divergences.json`][divergences] with its generated [reports/divergences.md][divergences-report], and [`AUDIT.md`][audit-doc] section 10 together give the canonical-versus-adapted split and the audit path it proposed. What is genuinely still missing is the push half, since every one of those detects drift while the sweep that fixes it is manual. Re-scoped, it carries the "Fleet Sweeps" visit manifest and Blog as the pilot. Closing it against the built machinery is the alternative, and it loses the only tracking issue the sweep would have. +- **Decide which `install-tools.sh` failures are collected and which end the run.** [`apply_tool`][install-tools] collects a non-zero return from a tool's install function, so one failure does not strand the rest, and several install paths call `die` instead and end the whole run. Two kinds are mixed there. A refusal is deliberate and should stay fatal: an unverifiable keyring, a checksum mismatch, or a declined prompt each mean nobody vouched for what would be installed, and continuing past one is worse than stopping. An upstream lookup that cannot be answered is the case the collection exists for, and `node_install` failing to read the current long term support line, `uv_install` finding no build for the architecture, and `dotnet_install` finding no package for the distribution each end the run today, which is one unreachable upstream stranding every tool after it. Changing those three to return non-zero is a behavior change across the install path, so it wants its own verification on each supported distribution rather than riding along with a migration. Raised by review on [#667][pr-667], where the comment above `apply_tool` claimed the collecting behavior for all of them and now states the split. + +## Verified Complete, Awaiting Close + +Each was checked against the tree and has nothing left to do anywhere. Closing is the maintainer's call, and each wants the evidence quoted in the closing comment rather than a bare close. + +Nothing is awaiting close today. [#578][issue-578] was the last entry here and closed on 2026-08-08, and the part of it the fleet still owes is carried by the re-vendor entry under "Fleet Sweeps", which names the three sections it touches. + +<!-- Issues --> + +[issue-305]: https://github.com/ptr727/ProjectTemplate/issues/305 +[issue-310]: https://github.com/ptr727/ProjectTemplate/issues/310 +[issue-353]: https://github.com/ptr727/ProjectTemplate/issues/353 +[issue-363]: https://github.com/ptr727/ProjectTemplate/issues/363 +[issue-364]: https://github.com/ptr727/ProjectTemplate/issues/364 +[issue-365]: https://github.com/ptr727/ProjectTemplate/issues/365 +[issue-379]: https://github.com/ptr727/ProjectTemplate/issues/379 +[issue-456]: https://github.com/ptr727/ProjectTemplate/issues/456 +[issue-483]: https://github.com/ptr727/ProjectTemplate/issues/483 +[issue-489]: https://github.com/ptr727/ProjectTemplate/issues/489 +[issue-509]: https://github.com/ptr727/ProjectTemplate/issues/509 +[issue-521]: https://github.com/ptr727/ProjectTemplate/issues/521 +[issue-523]: https://github.com/ptr727/ProjectTemplate/issues/523 +[issue-550]: https://github.com/ptr727/ProjectTemplate/issues/550 +[issue-558]: https://github.com/ptr727/ProjectTemplate/issues/558 +[issue-577]: https://github.com/ptr727/ProjectTemplate/issues/577 +[issue-578]: https://github.com/ptr727/ProjectTemplate/issues/578 +[issue-580]: https://github.com/ptr727/ProjectTemplate/issues/580 +[issue-585]: https://github.com/ptr727/ProjectTemplate/issues/585 +[issue-597]: https://github.com/ptr727/ProjectTemplate/issues/597 +[issue-607]: https://github.com/ptr727/ProjectTemplate/issues/607 +[issue-623]: https://github.com/ptr727/ProjectTemplate/issues/623 +[issue-633]: https://github.com/ptr727/ProjectTemplate/issues/633 +[issue-639]: https://github.com/ptr727/ProjectTemplate/issues/639 +[issue-671]: https://github.com/ptr727/ProjectTemplate/issues/671 +[issue-672]: https://github.com/ptr727/ProjectTemplate/issues/672 +[issue-673]: https://github.com/ptr727/ProjectTemplate/issues/673 +[issue-729]: https://github.com/ptr727/ProjectTemplate/issues/729 + +<!-- Pull requests --> + +[pr-591]: https://github.com/ptr727/ProjectTemplate/pull/591 +[pr-620]: https://github.com/ptr727/ProjectTemplate/pull/620 +[pr-667]: https://github.com/ptr727/ProjectTemplate/pull/667 + +<!-- Upstream --> + +[copilot-review-schema]: https://github.com/orgs/community/discussions/204320 + +<!-- Repo --> + +[agents]: ./AGENTS.md +[audit]: ./spec/audit.py +[audit-doc]: ./AUDIT.md +[bootstrap]: ./host-setup/bootstrap.sh +[bootstrap-ps1]: ./host-setup/bootstrap.ps1 +[codestyle]: ./CODESTYLE.md +[copilot-instructions]: ./.github/copilot-instructions.md +[divergences]: ./spec/divergences.json +[divergences-report]: ./reports/divergences.md +[editorconfig]: ./.editorconfig +[fidelity-honesty]: ./spec/fidelity_honesty.py +[files]: ./spec/files.json +[fleet-map]: ./docs/fleet-map.md +[governance]: ./GOVERNANCE.md +[host-setup-doc]: ./docs/host-setup.md +[host-setup-windows]: ./host-setup/windows/ +[install-tools]: ./host-setup/linux/install-tools.sh +[markdownlint]: ./.markdownlint-cli2.jsonc +[matrix]: ./reports/conformance-matrix.md +[operations]: ./OPERATIONS.md +[peer-messaging-doc]: ./docs/peer-messaging.md +[project-types]: ./spec/project-types.json +[prose-gate]: ./.github/actions/prose-gate/action.yml +[readme-sections]: ./spec/readme-sections.json +[readme-structure]: ./spec/readme-structure.md +[repo-gate]: ./scripts/repo_gate.py +[reports]: ./reports/ +[repos]: ./registry/repos.json +[reusable-workflows-doc]: ./docs/reusable-workflows.md +[scripts]: ./scripts/README.md +[secrets]: ./spec/secrets.json +[secrets-schema]: ./spec/secrets.schema.json +[section-model]: ./spec/section-model.md +[snippets]: ./catalog/snippets/ +[standup]: ./STANDUP.md +[third-party-tools]: ./spec/third-party-tools.json +[type-model]: ./spec/type-model.md +[validate-task]: ./.github/workflows/validate-task.yml +[workflow]: ./WORKFLOW.md +[workflow-reuse-report]: ./reports/workflow-reuse.md +[workflows]: ./catalog/snippets/workflows/ +[write-guard]: ./host-setup/agent-safety/gh-write-guard.py diff --git a/WORKFLOW.md b/WORKFLOW.md index 42b6293a..c988c99b 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -1,302 +1,302 @@ -# WORKFLOW.md - -The guide for CI/CD **workflows** (GitHub Actions): a deliberate mixture of code style, architecture, a **behavioral contract** (expected inputs and outputs), and a **test methodology**. Code style lives in [`CODESTYLE.md`][codestyle]. This file is its sibling for everything under [`.github/workflows/`][workflows]. - -Its defining principle: **it describes required outcomes, not a required implementation.** Two repos may implement the same guarantee with different YAML. A workflow is correct when it **satisfies the contract** in section 4 and is **defect-free against the expected inputs and outputs**, not when it matches a reference implementation byte for byte. The conventions in section 2 keep workflows legible. The contract in section 4 is what they must *do*. - -Given this document, an agent must be able to do three things to any project: - -1. **Audit** - statically check the workflows against the conventions (section 2) and the structural facts each guarantee implies (section 5A). -2. **Test** - trace the expected inputs/outputs (section 5B) and, where warranted, drive a live probe (section 5C). -3. **Assess** - render a verdict: **operational** (every *applicable* guarantee holds and every *applicable* scenario's observed output equals the expected) or **not operational** (any mismatch, which is a *defect*, not a style nit). - -> **Canonical scope.** This document is authoritative for the workflow contract and test methodology (sections 3 to 6). The conventions in section 2 and the release policy also live in `GOVERNANCE.md` ("Workflow YAML Conventions" and "Release Model"), which is authoritative where the two overlap. Section 2 restates them so this file reads on its own. On any conflict in that overlap, `GOVERNANCE.md` wins. - -The guarantees are distilled from failures observed in practice and stated as the **failure-mode each prevents**, so the document stays portable to any project. - -## 1. Purpose and How to Use This Document - -- **Contract, not implementation.** Conform to the *outcomes* in section 4. Shape, job names, and file layout may differ between repos, but the input/output behavior may not. -- **Applicability.** A guarantee (or a 5A check, or a 5B scenario) is **applicable** only if the repo contains the construct it governs: a given target, a transfer artifact, a registry push, a wrapper-version source. An item that governs an absent construct is **N/A**: record it as N/A and **exclude it from the verdict**. N/A is never a defect. Section 6 names which items go N/A per project type. A near-empty pipeline (source-only) is mostly N/A and that is fine. -- **Operational is binary.** A workflow is operational only if every *applicable* guarantee holds. A single applicable input/output mismatch is a defect and makes the workflow non-operational, regardless of how clean the YAML looks. -- **Default branch.** Guarantees say "default branch" portably. It is implemented as the literal `main` in several places (the validate gate, the `prerelease` expression, and `version.json`'s `publicReleaseRefSpec`). These MUST all reference the repo's *actual* default branch. A divergence is a defect (section 5A). -- **Two layers when auditing.** The pipeline splits into an **orchestrator** layer (the PR entry workflow, the publisher, and the version/release/badge jobs) and a **build-leaf** layer (`build-<target>-task.yml`). Inputs like `github`/`nuget`/`dockerhub`/`expect_release_assets` live on the orchestrator. A leaf only ever receives `ref`/`branch`/`smoke` (and a derived `push`). When a check names an input, assert it in the layer that declares it. -- **The three verbs.** Audit (static), Test (trace + probe), Assess (verdict). Section 5 gives the exact procedure. - -## 2. Workflow Style Conventions - -Prescriptive style/legibility rules. Cheap to check, necessary but not sufficient (a perfectly styled workflow can still violate section 4). - -- **Action pinning.** Pin **every** action to a commit SHA with a trailing `# vX.Y.Z` comment. Use `# vX` only when the upstream floating major tag has no specific patch SHA. The single documented no-pin exception is a tool whose tag stream lags `master` such that tag-tracking would downgrade (here, `dotnet/nbgv@master`). Invent no others. -- **Filename.** Reusable workflows (`on: workflow_call`) end in `-task.yml`. Entry-point workflows do not (`-pull-request.yml`, `-release.yml`). Lowercase, hyphen-separated. -- **Workflow `name:`.** Reusable names end in **"task"**. Entry-point names end in **"action"**. -- **Job and step `name:`.** Every job ends in **"job"**, every step in **"step"**, including a ruleset-bound required-check job, whose `name:` and the ruleset `context:` are one string renamed together (never independently). -- **Concurrency.** Top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }`. Document exceptions inline (D7). -- **Shells.** Every multi-line bash `run:` (and every committed `.sh` script) starts `set -Eeuo pipefail`. -- **Conditionals.** Multi-line `if:` uses the folded scalar `if: >-`. -- **Boolean inputs.** A boolean used by both `workflow_call` and `workflow_dispatch` is declared in **both** trigger blocks, and `workflow_dispatch` delivers the **string** `"true"`/`"false"`, so any `if:` compares both forms: `${{ inputs.foo == true || inputs.foo == 'true' }}`. -- **Reusable-workflow permissions.** Job-level `permissions:` are validated **before** `if:`, so even a skipped job needs valid permissions. Grant least privilege. A reusable callee's extra scope (e.g. `actions: write` for cleanup) is granted by the **caller**. -- **Allowlist `success` and `skipped` explicitly** across optional dependencies (`!= 'failure'` lets `cancelled` through). -- **Docker layer cache.** Cache to/from a registry tag (`type=registry`), never `type=gha`. -- **Line endings.** Workflow YAML is LF (Actions and Dependabot rewrite it that way). Other files follow `.editorconfig`, and committed JSON state files follow the repo's JSON rule. Preserve endings on every edit. - -## 3. Architecture - -### Branch Model - -Two workflow models, set per repo by the registry `workflowModel` field. `release` (default) is the feature-branch pipeline this document specifies: - -```mermaid -flowchart LR - feature[feature branch] -->|squash| develop - develop -->|merge commit| main - main -.->|no back-merge| develop -``` - -`operational` repos (live-service config, `workflowModel: operational`) commit directly to `develop` and promote a known-good snapshot to `main` via an occasional PR: - -```mermaid -flowchart LR - edit[direct signed commit] -->|advisory CI| develop - pr[pull request] -->|lint CI, reported not required| develop - develop -->|merge commit, enforced lint CI| main -``` - -The direct commit is an **allowance, not a substitute for review**. The ruleset drops the pull-request *requirement*, which permits a direct push without withdrawing the pull request, so a change worth reviewing still takes one and both paths reach `develop` legally. Which changes those are is stated as a shape rather than a line count in [GOVERNANCE.md "Operational Repositories"][governance-operational-repositories], which owns the test and is the one place it is written, since nothing in a ruleset can apply it. What differs is when validation lands. On the direct-commit path the commit is already on the branch, so CI can only be advisory after the fact, and that is the accepted cost of the model. On the pull-request path the change has not landed, so validation is pre-merge and actionable, which is the moment it is worth the most, and the lint workflow's `pull_request` trigger therefore names `develop` alongside `main` (Section 6). That is what makes **D1.2** hold here, since its input is *any* PR and the operational model is no exception. The check is reported on a `develop` PR rather than required, because a required status check on `develop` binds the direct push too and would dissolve the allowance the model is built on. - -Their CI is lint/validation only (editorconfig/EOL plus domain linters such as Home Assistant or ESPHome config validation or a firmware build, but **no unit tests**), so the D-guarantees below that assume a build/test pipeline are **N/A** exactly as for `source-only` (Section 6). What binds: the promotion gate, where the `develop -> main` PR must pass the required `Check pull request workflow status job`, and the source-only release on manual dispatch (`releaseTrigger: dispatch-only`; tag + source zip). Branch-model rulesets are specified in [GOVERNANCE.md "Branching Model"][governance-branching-model] and [repo-config/README.md][repo-config-readme], not here. - -### 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, the date-badge job, and the `changes -> smoke-build -> aggregator` shape of the PR workflow. These job *bodies* should not need per-repo edits. -- **Build** is repo-owned: the `build-<target>-task.yml` leaf tasks. -- **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_<target>` inputs and the `build-<target>` job + its `github-release` `needs:` entry in the release task, **and** the `changes` paths-filter entry + output + the `smoke-build` enable-forward in the PR workflow. "Verbatim" applies to the `github-release` job and the version/publish-plan logic, not 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-<branch>-library` producer needs a new `enable_library` input, a `build-library` job, a `needs:` entry, and a `library` paths-filter). - -### The Seam Contract - -A target contributes a file to the GitHub release by uploading a workflow artifact named `release-asset-<branch>-<target>`. The release job collects **every** matching artifact by **pattern** (`pattern: release-asset-<branch>-*` + `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. - -```mermaid -flowchart LR - leafa[leaf: target A] -->|release-asset-branch-A| store[(run artifacts)] - leafb[leaf: target B] -->|release-asset-branch-B| store - store -->|pattern + merge-multiple| rel["github-release job (D6)"] - reg[registry leaf: nuget / pypi / docker] -->|push, no asset| registries[(registries)] -``` - -### 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. - -### 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<sha>`. `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. - -### Validate-at-Entry - -When a workflow's inputs carry a cross-input or input-versus-derived-state invariant, assert it **once** in a dedicated entry job/step the downstream jobs `needs:`, failing fast with `::error::` before any build or publish. - -### Resource Lifecycle - -Workflow artifacts are an **intra-run handoff** only. Durable copies live on the release/registry. The rule: a transfer artifact handed **between jobs** is deleted by exact name/pattern **at its point of consumption**, the delete is **gated to the same condition as the consumer**, 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, and an intermediate consumed only within the same run (e.g. an executable's per-runtime outputs feeding an aggregation step) may rely on the retention backstop alone. The run is **never** blanket-deleted (`.artifacts[].id`). See D5. - -### Fast PR Feedback - -PRs validate fast and never publish: a paths-filter smoke-builds only changed targets. A validation job always runs. Smoke builds compile/lint/test but upload nothing and push nothing. One required aggregator gates the merge. See D1. - -```mermaid -flowchart TD - pr[pull request] --> ch[changes paths-filter] - ch -->|target changed| sb[smoke-build changed targets] - ch -->|workflow-only or docs| skip[smoke-build skipped] - val[validation job] --> agg["Check pull request workflow status job (D1)"] - sb --> agg - skip --> agg - agg -->|success| ok[merge allowed] -``` - -### Release Model - -Each publish builds a **single branch**, the trigger ref (`main` a release, `develop` a prerelease), so there is no branch matrix and `github.ref` always names the built branch. A **human merge never auto-publishes**: a 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 or push to their own registry. An unchanged version re-pushes nothing (no-op republish). Docker re-pushes by design. - -```mermaid -flowchart TD - trig[main-only schedule / dispatch / paths-filtered push] --> one[build the one trigger branch] - one -->|main| vmain["version X.Y.Z stable (D3)"] - one -->|develop| vdev["version X.Y.Z-g-sha prerelease (D3)"] - vmain --> relm["github-release + registries: latest (D4)"] - vdev --> reld["github-release + registries: prerelease (D4)"] -``` - -### Output Seam by Destination - -Pick each output's path by **where the artifact goes**: - -- **File on the GitHub release** (zip, binary, packaged library): one leaf per output uploading `release-asset-<branch>-<name>`. The repo keeps `expect_release_assets: true` (its default). -- **Package-registry push** (NuGet, PyPI): the leaf builds and publishes to its registry. NuGet pushes from the leaf *and* uploads a `release-asset-*`. PyPI is **split**: the leaf only builds + uploads its build artifact, a separate publish job does the OIDC upload (so `id-token: write` is granted at one entry point, behind an environment gate) and contributes **no** `release-asset-*`. -- **Image-registry push** (Docker): the leaf pushes the default branch multi-arch (amd64+arm64) and any other branch `amd64`-only (arm64 emulation is reserved for the released image), and contributes no `release-asset-*`. -- **Filesystem on a host the project owns** (a static site, a config tree): the leaf builds the tree, ships it to the host, and contributes no `release-asset-*`. The transport is the repo's own. What the contract fixes is that the deploy is a **separate `workflow_dispatch`** from the release, so a redeploy of an unchanged commit mints no tag and a host rebuild, a rollback, or proving a branch on a non-production environment costs nothing; that its credentials come from a **per-environment GitHub Environment** rather than the repository secret store; and that the deploy ends by asserting **what the host serves** rather than the transport's exit status (D4.6). Retention at the destination is bounded by a declared count with one side recorded as owning the prune, which is the deploy where its credential can observe the destination and the host where that credential is deliberately write-only (D5.6). -- **No file target via the release task** (Docker-only, PyPI-only): the release is tag + source zip + README + LICENSE. The repo's **caller MUST pass `expect_release_assets: false`** to the release task (the input is never set by a publisher that ships file targets, which keeps the default `true`). This is the one case where the otherwise-verbatim publisher is edited. With the default `true` and no assets, the release-create step fails on `fail_on_unmatched_files`. A **source-only** repo has no release task at all. Its standalone `publish-release.yml` inlines `action-gh-release`, so `expect_release_assets` does not apply (see Section 6). - -## 4. Behavioral Contract: Expected Outcomes - -The required behaviors, organized by domain. Each is a **MUST**, stated as input -> output plus the failure-mode it prevents. A workflow that violates any *applicable* guarantee is **not operational**. - -### 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). *Prevents: rebuilding everything, and a changed target slipping through unbuilt.* -- **D1.2 A validation job always runs.** Input: any PR. Output: a type-appropriate validation job runs unconditionally and the aggregator `needs:` it. In a .NET repo this is the `unit-test` job (format/style/test). A non-.NET repo **replaces** it (not deletes) with its own validator (lint, schema-check) and re-points **every** `needs:` on it (both the aggregator and `smoke-build`, which `needs:` the validation job by name) to the replacement. *Prevents: a PR merging with no validation, or a dangling `needs:` that fails the whole workflow to load.* -- **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 `!smoke`). *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 excludes workflow files, so smoke-build skips. *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**, `needs:` the changes job and the validation job, 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's validation/test job. Output: tests run with coverage collection (`dotnet test --collect:"XPlat Code Coverage"` or `pytest --cov-report=xml`) and a `codecov/codecov-action` step uploads it, **best-effort** (`continue-on-error` and/or `fail_ci_if_error: false`, so a Codecov outage or an absent token never reds the gate). `CODECOV_TOKEN` lives in the repo's **actions** secret store and reaches the reusable validator via `secrets: inherit`. Required for **every** C# and Python repo that has tests (see `spec/secrets.json` `typeMechanisms`). The repo also ships a **`codecov.yml`** that sets the project and patch statuses to **`informational: true`** so a coverage delta never gates a PR (a distinct knob from `fail_ci_if_error`, which only guards the upload step), and excludes intentionally-untested, non-shipped code (an example/demo or benchmark project) from the coverage denominator via `ignore`. A repo may override this to enforce a coverage threshold where its quality bar requires it. Coverage output is a build artifact, so `.gitignore` excludes it (e.g. `coverage/` and `*.cobertura.xml`, with `.gitignore` the full source of truth) so a blanket `git add -A` won't stage the untracked output. *Prevents: coverage silently going unreported; a stale, unused token; a coverage regression blocking an unrelated PR; a coverage artifact committed by a blanket add.* - -### 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.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.* - -### 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<sha>`. 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<sha>` 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.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.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 is a tag on the built commit plus the auto source zip, README, and LICENSE; file-producing targets attach `release-asset-*`; `prerelease` equals `branch != default`. A no-file-target repo that uses the release task (Docker-only, PyPI-only) reaches the tag-only shape **only** with `expect_release_assets: false` set by the caller (which relaxes `fail_on_unmatched_files` and skips the asset download). With the default `true` and no assets the release-create step fails. A source-only repo reaches the same shape through its inlined `action-gh-release` instead, with no release task or `expect_release_assets`. -- **D4.4 No-op republish.** Input: a re-run whose version is unchanged. Output: nothing is re-pushed, because the release-create step is skipped when the tag exists (refreshed only on `workflow_dispatch`), and the paired asset-delete is skipped with it. 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, so a failed build skips it (no tag, no release), and the terminal registry pusher (Docker) needs every other build and guards its `if` with `!failure() && !cancelled()`, so a failed build skips docker too (no image push) while a disabled or unchanged target (skipped, not failed) still lets docker build on smoke. *Prevents: a partial publish, e.g. a Docker image pushed while the executable build failed and no release was cut.* A repo pushing two registry targets at once would need a build/publish split behind an all-builds gate, which none does today. -- **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 (e.g. an executable's per-runtime outputs feeding an in-run aggregation) MAY instead rely on the `retention-days: 1` backstop. *Prevents: transfer artifacts accumulating against the storage quota.* -- **D5.2 Gate the delete to the consumer's condition.** Output: the delete runs under the **same** condition as its consuming step. Where the consumer is conditional (the GitHub release create), the delete is conditional too. Where the consumer always runs when its job runs (the PyPI publish step), the delete always runs, so on a no-op re-run the `release-asset-*` delete is **skipped** while the PyPI build-artifact delete still **runs** (its publish ran). *Prevents: deleting freshly built assets on a no-op re-run.* -- **D5.3 Best-effort.** Output: cleanup is `continue-on-error`, tolerates a failed listing, and deletes **all** matching ids. *Prevents: a cleanup hiccup reddening a job whose publish succeeded.* -- **D5.4 Retention backstop.** Output: **every** `upload-artifact` sets `retention-days: 1`. -- **D5.5 Never blanket-delete.** Output: cleanup MUST NOT enumerate and delete the run's whole artifact set. *Prevents: destroying diagnostic/log artifacts and auto-emitted build-records.* -- **D5.6 A durable destination's retention is bounded and owned.** Input: a deploy that installs a release beside the retained ones on a host the project owns. Output: retention is bounded by a **declared count**, and the side owning the prune is **written down**. Where the deploy credential can observe the destination, the deploy asserts the count converged and fails when it does not. Where the credential is deliberately write-only, so it can neither delete nor read back, the prune belongs to the **host** and that ownership is recorded there: widening the credential to reach the destination would trade a real confinement boundary for a check, which is the wrong trade. The release the live pointer resolves to is never a prune candidate, whatever the sort order says. A prune that runs against a local scratch tree, or that is best-effort, or that no side is recorded as owning, satisfies none of this. Unlike D5.1 through D5.4, this destination is durable rather than a run-scoped artifact, so no retention backstop expires it. *Prevents: a destination growing without bound until the disk fills, which surfaces as a site outage rather than as a failed deploy; and the split-ownership version of the same, where each side assumes the other prunes.* - -### D6 - Seam / Architecture Conformance - -- **D6.1 Pattern handoff.** Output: the release job downloads by `pattern:`/`merge-multiple:`, not `artifact-ids:`. Targets upload `release-asset-<branch>-<target>`. Canonical for single-target. -- **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_<target>` input, the `build-<target>` job and its `github-release` `needs:` entry, the `changes` paths-filter entry + output, and the `smoke-build` enable-forward (and, for PyPI, the separate `publish-pypi` job). The `github-release` job body stays verbatim. *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 Skipped jobs still need valid permissions.** Output: every reusable job declares valid `permissions:`. 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. -- **D7.3 Boolean inputs both forms.** Output: declared in both trigger blocks, compared against `true` and `'true'`. -- **D7.4 Optional-dependency chaining.** Output: cross-job conditions allowlist `success`/`skipped` explicitly. - -### D8 - Bots / Automation - -- **D8.1 Merge-bot.** Output: enables auto-merge on `opened`/`reopened` for **every** Dependabot tier including semver-major (the required checks are the gate, not the bump magnitude); dispatches `--squash`/`--merge` by the PR's base ref; disables on a maintainer-pushed `synchronize`; concurrency keyed on the **PR number**, not `github.ref`. *Prevents: two PRs colliding in auto-merge.* -- **D8.2 CodeGen and Dependabot.** Output: codegen runs as a matrix over both branches and is deterministic from an external source. Dependabot targets both branches, security PRs to default. -- **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it. The `main` pin push publishes via the release gate, while a `develop` pin does not auto-publish. It ships via a `develop` dispatch (prerelease) or the next promotion to `main`. The tracker's `bump-branch-prefix` + `branches` MUST match a merge-bot rule, one of the built-in `<prefix>-<base>` head/base pairs or a `rules` entry the caller passes, or auto-merge silently never fires. -- **D8.4 An identity allowlist used as a gate fails loud.** Where a gate compares `github.actor` (or a PR author) against hard-coded bot identities, the non-matching branch on an otherwise-legitimate trigger **emits a `::warning::`** rather than falling through silently. Output: a run that declines to act on an unrecognized identity is visibly annotated. *Prevents: the App being renamed, replaced, or reinstalled under a new slug, after which the comparison quietly evaluates false and the gate stops firing, a green and silent run that looks identical to a healthy one.* The masking matters most where a second path hides the loss: a weekly schedule keeps publishing, so the only symptom is release *timeliness*, easily missed for months. Where the failure is self-announcing instead (the merge-bot simply stops merging, so bot PRs visibly pile up) an annotation is optional. Resolving the identity at run time (mint an App token, read `GET /app`) removes the hard-coded string entirely and is the escalation if an allowlist proves fragile in practice. - -### D9 - Style / Static (See Section 2) - -- **D9.1** Every action SHA-pinned with a version comment (sole exception: the documented lagging-tag tool). -- **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.4** Docker layer cache targets a registry tag, not `type=gha`; `cache-to` writes only the built branch's `buildcache-<branch>` and only on push, while `cache-from` reads both branches; multi-image repos use a per-image cache tag. -- **D9.5** Line endings follow `.editorconfig`. - -## 5. Test Methodology - -An agent verifies a project in three escalating modes, then renders a verdict. **Skip N/A items** (section 1): a check or scenario for an absent construct is recorded N/A, not failed. - -### 5A. Static Audit (No Execution) - -Read the workflow files plus `version.json` and assert the structural fact behind each *applicable* D-guarantee, each pass/fail/N-A with a `file:line` citation. Remember the two layers: assert each input in the file that declares it. - -**Core (every repo):** - -- **D1:** a `changes` paths-filter job exists, covers each of the repo's targets, and **excludes** `.github/workflows/**`; the PR entry workflow's smoke call sets `github/nuget/dockerhub: false` on the release task; the leaf receives `smoke: true` and a derived `push` (false on smoke); every build-task `upload-artifact` (and any aggregation job) is gated `!smoke`; the aggregator `needs:` the `changes` and validation jobs, blocks on `failure`/`cancelled`, passes on `skipped`; a validation job runs unconditionally. -- **D2:** an entry validation job/step exists per complex-input workflow; the release gate checks both directions, strips `+buildmetadata`, and skips on smoke; the publisher rejects a dispatch from a ref other than `main` or `develop`. -- **D3:** each run builds one branch, so NBGV classifies `github.ref` directly (no `IGNORE_GITHUB_REF`), and the default-branch literal in the gate (`== 'main'`), the `prerelease` expression (`!= 'main'`), and `version.json`'s `publicReleaseRefSpec` all name the repo's actual default branch. -- **D4:** `target_commitish` is the NBGV commit id; `prerelease` equals `branch != default`; the release-create step is gated `exists == 'false' || github.event_name == 'workflow_dispatch'` (the step output is the string `'false'`, not a boolean); the asset-delete step is gated identically. A dispatch-only publisher (`releaseTrigger: dispatch-only`) may omit the gate and the exists-check entirely: every run is a dispatch, so the skip leg can never fire and create-or-refresh is unconditional. Record the gate N/A there, not missing. -- **D5:** each cross-job transfer artifact has a delete step at its consumer, gated to the consumer's condition, `continue-on-error: true`, looping all ids; **every** upload sets `retention-days: 1`; **no** `.artifacts[].id` blanket delete exists anywhere. -- **D6:** the release download uses `pattern:`/`merge-multiple:` (no `artifact-ids:`). Branch-derived config reads `inputs.branch` (a `github.ref_name` in such config is a finding). Artifact names are branch-suffixed. The target set is consistent across the release task and the paths-filter. -- **D7:** the publisher concurrency group is ref-independent with `cancel-in-progress: false`. Reusable jobs declare permissions. Boolean `if:` uses both forms. -- **D8/D9:** merge-bot concurrency keys on PR number. The upstream tracker's branch prefix matches a merge-bot rule (wrapper repos). Actions are SHA-pinned. Names/shells/conditionals follow section 2. - -**Per-type addenda (apply only the ones present):** - -- **Console/executable:** the smoke runtime matrix is a strict non-empty subset of the full matrix. The per-runtime outputs (`publish-<branch>-<runtime>`) are aggregated by `pattern:` + `merge-multiple:` into one `release-asset-<branch>-<target>` and the aggregation job is gated `!smoke`. The per-runtime intermediates rely on the retention backstop (no explicit delete is required for an in-run intermediate). -- **NuGet:** the publish step is gated `if: inputs.push` only (not on an existence check) and uses `--skip-duplicate`. `*.nupkg` push also carries the paired `.snupkg` to the symbol server where symbols are enabled. The `release-asset` zip carries the package(s). -- **PyPI:** `publish-pypi` declares `environment: { name: pypi }`. `id-token: write` appears only on that job (absent from the build/PR path). `skip-existing: true` is set on the publish action. The build artifact is deleted after publish. The `pypi` environment has a deployment-branch rule. -- **Docker:** a Docker-only repo's caller passes `expect_release_assets: false`. The leaf reads the external state file for the tag instead of `SemVer2` (wrapper repos only, since a plain Docker repo correctly tags off `SemVer2` and records this N/A). The readme/date-badge jobs are gated main-only. The docker-readme task validates `repositories` XOR `manifest`+`manifest-jq`. The buildcache follows D9.4. -- **Static site deployed to a host:** the generator is pinned by version **and** by a checksum verified before install, declared once across the workflows that install it. The deploy is a dispatch carrying an environment choice, with concurrency keyed on the **environment** and `cancel-in-progress: false`, and production gated to the default branch while any ref may reach a non-production environment. The reusable callee re-asserts the environment name in a job of its own. The upload targets a per-release directory and carries no delete flag at the environment root, and the pointer flip is a separate step. The terminal check asserts the golden-list length floors first, then the environment, then the release id, then the URL contract. Retention is bounded by a declared count and one side is recorded as owning the prune: the deploy asserts it where the credential can observe the destination, and the host owns it where the credential is confined write-only (D5.6). - -### 5B. End-to-End Trace Scenarios (No Execution, Deterministic from the YAML) - -For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the inputs and emit the predicted **run/skip + version + release + artifact-end-state** table, then compare to the expected. Scenarios that exercise an absent target are N/A. Minimum set: - -| # | Input | Expected output | Exercises | -| --- | --- | --- | --- | -| S1 | PR touching a build target | `changes` flags it; validation runs; that target's smoke build runs; no push, **no uploads**; validate-release **skipped (smoke), succeeds**; release **skipped**; aggregator **success**; version = prerelease; no release; no dangling artifacts | D1, D2.2, D3 | -| S2 | PR changing only docs | smoke-build **skipped**, validation runs, aggregator **success** | D1.1, D1.5 | -| S3 | PR changing only `.github/workflows/**` | filter excludes -> smoke-build **skipped**, aggregator **success** | D1.4 | -| S4 | PR base = default branch, carrying a build target | smoke versions as prerelease, validate-release **skipped (smoke)** so the default-branch arm does **not** fire, aggregator **success**, promotion not blocked | D1.3, D2.2 | -| S5 | bot push to `main` not touching a release path (e.g. an Actions bump) | the paths filter excludes it, so nothing publishes | D4.1 | -| S6 | code-affecting **bot** push to `main` (a human push/promotion, or any develop push, does not) | the `plan` job gates it to the App/Dependabot actor, and `main` publishes a release | D3, D4 | -| S7 | publish run (schedule, a bot push to main, or a dispatch) | builds the **one** trigger branch: `main` -> `X.Y.Z`, `prerelease=false`, registry stable, badge/readme run; `develop` -> `X.Y.Z-g<sha>`, `prerelease=true`, registry prerelease; `release-asset-*` consumed-then-deleted; PyPI build-artifact deleted after its publish; **no dangling artifacts** | D3, D4, D5, D6, D7 | -| S8 | dispatch from a ref other than `main` or `develop` | **fails fast** | D2.3 | -| S9 | re-run publish, version unchanged | release-create **skipped**, `release-asset-*` delete **skipped**; NuGet/PyPI pushes no-op (server dedupe); **PyPI build-artifact still deleted** (its publish ran); **Docker still re-pushes** the image; no duplicate release | D4.4, D5.2 | -| S10 | branch/version classification disagree | validate-release **fails loud**, build/publish skip | D2.2 | -| S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a `<prefix>-<branch>` PR -> merge-bot auto-merges -> the `main` pin publishes via the gate (a develop pin does not auto-publish, shipping instead via a develop dispatch or promotion) | D8.3, D3.5 | -| S12 | deploy dispatch naming an environment | the ref gate runs **first** (production from the default branch only, any ref to a non-production environment); validation runs; the callee re-asserts the environment name; a release installs under its own id; the pointer flips as a separate step; retention is bounded by whichever of the two D5.6 shapes the repo uses, so a deploy whose credential can observe the destination asserts the count converged and one confined write-only leaves it to the host; the live check asserts the environment and the release id, waiting out the reload, then the URL contract; **no tag and no release are created** | D2.1, D4.6, D5.6, D7.1 | -| S13 | deploy dispatch of a production environment from a non-default ref | **fails fast**, before anything is installed or written | D2.1, D2.3 | - -### 5C. Live Probe (Where Warranted) - -- Open a trivial-change PR touching one target and confirm S1. -- Drive a `smoke: true` push-probe of the build task for **both** the default and a non-default branch and assert the version classification (clean vs prerelease) and that the gate passes, **without publishing**. *Caveat: the Docker leg logs in to the registry even on smoke and reads the buildcache, so it needs `DOCKER_HUB_*` secrets and cannot run on a fork PR (same-repo only).* -- Per registry: after a real publish, query NuGet.org for the expected version + prerelease classification (and the `.snupkg` on the symbol server), and confirm a re-run added no duplicate. For PyPI inspect the `Compute PyPI version step` log and the built `dist/*` filenames for `.dev0` off `develop` vs a plain version on the default branch. -- Inspect the latest real publish's logs for `PublicRelease`/`SemVer2` per leg and confirm the artifact lifecycle (uploaded, consumed, deleted, with none left behind). -- **The deploy ref gate (S13) is verified only by tripping it, and the dispatch that trips it is the maintainer's to run.** Dispatch the production environment from a non-default ref and expect the run to fail at the gate. The evidence is four things, and each of them matters: the gate job's conclusion, its error text naming the expected and the received ref, every downstream job recorded as **skipped** rather than passed, and the deployment count against the production environment unchanged. Capture all four, because a gate that fails open and a gate nobody tripped produce the same empty run history, so "we have never seen it fail" is not evidence about the one control standing between a mis-dispatch and the live site. **The agent prepares the command and reads all four back afterwards. It does not fire it.** An agent harness may refuse to dispatch a production deploy, which is the harness working as intended, and the refusal is neither re-shaped into a raw API call nor talked around (GOVERNANCE.md "Repository Boundaries and Write Safety"). The same split applies to any probe that acts on the deploy host directly, an outbound SSH exercising a forced command among them. - -### Assessment - -The workflow is **operational** iff every *applicable* 5A item passes and every *applicable* 5B scenario's observed output equals the expected (confirmed by 5C where a live signal exists). N/A items are excluded, never counted as failures. Any *applicable* mismatch is a **defect** -> **not operational**. Procedure: - -1. **Audit** with 5A. Record pass/fail/N-A with `file:line`. -2. **Trace** the applicable S-scenarios with 5B. Diff predicted vs expected. -3. **Probe** with 5C only for guarantees a static trace cannot settle (live version classification, registry state, artifact lifecycle). -4. **Verdict:** operational / not operational, with the failing guarantee(s) and the triggering input for each, and the list of items recorded N/A. - -## 6. Per-Project-Type Test Walkthroughs - -Each type maps the *applicable* S-scenarios onto its targets. The differences are which leaf tasks exist and what each produces, which 5A addenda apply, and which scenarios are N/A. Walking these is the self-check that the contract holds for each shape. - -- **Console / executable application.** Target produces `release-asset-<branch>-executable` (a 7z archive, `Console.7z`) by building a per-runtime `dotnet publish` matrix, then an aggregation job downloads the per-runtime `publish-<branch>-<runtime>` intermediates (`pattern:` + `merge-multiple:`), zips them, and uploads the single asset. Smoke builds a strict subset of runtimes. The per-runtime upload **and** the aggregation job are both gated `!smoke`, so smoke uploads nothing. The per-runtime intermediates rely on `retention-days: 1` (no explicit delete). Test: S1 with a console change smoke-builds the subset and uploads nothing; S7 attaches the 7z, `prerelease=true` on the non-default leg and `prerelease=false` on the default leg (GitHub auto-marks the stable default release "Latest", and the workflow does not set it). -- **NuGet library.** The leaf both pushes (`dotnet nuget push *.nupkg --skip-duplicate`, gated `if: push` only) and uploads `release-asset-<branch>-nugetlibrary`. Configuration is Release on the default branch, Debug otherwise. Where symbols are enabled (`snupkg`), the push auto-carries the paired `.snupkg` to NuGet.org's symbol server and the asset zip also contains it, a triple surface. NuGet.org derives `isPrerelease` from the SemVer2 `-g<sha>` suffix (the workflow sets no such flag). Test: S7 non-default leg publishes a prerelease package + asset, default a stable; S9 re-run is a server-side `--skip-duplicate` no-op. 5C: query NuGet.org for both versions and the symbol package. -- **PyPI library.** The leaf builds + uploads `pypilibrary-build-<branch>`. A **separate** `publish-pypi` job (with `environment: pypi`, `id-token: write`, `actions: write`) does the OIDC Trusted-Publishing upload with `skip-existing: true`, then **consume-then-deletes** the build artifact, **unconditionally on consume**, so on S9 it is deleted even though the `release-asset-*` delete is skipped. The version is `AssemblyFileVersion` with `.dev0` appended on `develop` only, and must stay `--pre`-selectable and sorted above the default release. PyPI contributes no `release-asset-*`. A PyPI-only repo sets `expect_release_assets: false` at the caller. Test: S7 default leg publishes a release, non-default a `.dev0`; S9 is a `skip-existing` no-op; 5C inspects the `dist/*` filenames and the compute-version log. -- **Docker image.** The leaf pushes the default branch multi-arch (amd64+arm64) and any other branch `amd64`-only, with a per-branch registry buildcache (`buildcache-<branch>`; a multi-image repo adds a per-image tag) (`cache-to` only the built branch and only on push, `cache-from` both branches); no `release-asset-*`, so a Docker-only repo's caller passes `expect_release_assets: false`; the readme (`peter-evans/dockerhub-description`, `DOCKER_HUB_ACCESS_TOKEN`) and date-badge jobs run **only** when the default branch publishes; the docker-readme task validates `repositories` XOR `manifest`+`manifest-jq` and a multi-image repo derives its publish matrix from the manifest. Docker **always re-pushes** the image, independently of a skipped release-create (S9). A **wrapper** repo tracks an upstream release: the upstream tracker writes a `name -> version` state file and the merge-bot auto-merges the bump PR (S11), and the leaf MUST read that file for the immutable tag instead of `SemVer2` (the tracker ships without this consumer wiring). Test: S7 default leg pushes `latest` + the version tag and updates readme/badge. Non-default pushes the develop tag (amd64 only). S9 still re-pushes. S11 ships the bumped upstream version next publish. 5C Docker probe needs `DOCKER_HUB_*` secrets and same-repo (not fork) runs. -- **Data / asset library.** A single new leaf: validate -> zip -> upload `release-asset-<branch>-library` (`retention-days: 1`, upload gated `!smoke`, mirroring the nugetlibrary leaf's shape). Because no such leaf ships, you **add a target** (D6.4): a new `enable_library` input + `build-library` job + `github-release` `needs:` entry in the release task, and a `library` paths-filter entry + `changes` output + `smoke-build` enable-forward in the PR workflow (without it, D1.1 never smoke-builds the library). Keep `expect_release_assets: true` (it has a file target, unlike Docker). The .NET `unit-test` job is replaced by a type-appropriate validator with the aggregator **and** `smoke-build` both re-pointed to it (D1.2/D1.5). `version.json` + the NBGV `get-version` step are retained (they own the tag). Test: S1 smoke runs validate+zip and uploads nothing; S7 attaches the zip, prerelease on the non-default leg; S9 on a *scheduled* re-run release-create + asset-delete skip (the existing zip is untouched, no registry push), while a `workflow_dispatch` re-run **refreshes** the release and re-runs the asset-delete (the asset is re-uploaded then re-deleted). N/A: the nuget/pypi/docker/executable 5A addenda and their scenario clauses. -- **Source-only / no build.** There is no `build-release-task.yml` (its `appliesTo` excludes source-only) and no package/image leaf, so nothing is edited down. The release is a standalone dispatch-only `publish-release.yml` that inlines NBGV for the tag and `action-gh-release` for the release: tag + source zip + README + LICENSE, with no reusable release task and no asset download. With no target the paths-filter matches nothing, so `smoke-build` is **structurally always skipped**, and validation is carried solely by the (replaced, non-.NET) validation job that the aggregator and `smoke-build`'s own `needs:` must both point at (D1.2, or drop the never-running `smoke-build` job). NBGV and `version.json` are still retained (they own the tag). Its publish job gates on the repo's reusable validation task (`needs:` the same `workflow_call` job the PR workflow runs), so a dispatch cannot release a ref that fails validation. Applicable scenarios: S1 (validation only), S5/S6 (publish gating), S7 (tag-only release), S8 (dispatch guard), S9 (no-op republish), S10 (classification gate). N/A: S2-S4 (assume a smoke-built target), the artifact-lifecycle and registry clauses of S7/S9, the D5/D6 artifact items, and all per-type 5A addenda, all recorded N/A, not failed. -- **Static site deployed to a host the project owns.** Two independent surfaces, and keeping them apart is the point. The **release** is the source-only shape above, unchanged: a dispatch-only `publish-release.yml` where NBGV and `version.json` own the tag, producing tag + source zip + README + LICENSE. The **deploy** is its own `workflow_dispatch` carrying an `environment` choice input, so redeploying an unchanged commit mints no tag, which matters because redeploying is routine. It runs a ref gate **first**, before anything is installed or written (production from the default branch only, while any ref may reach a non-production environment, since proving a branch before it merges is what that environment is for), then the **same** reusable validation task the PR gate runs, so a dispatch cannot deploy a ref that fails validation, then calls `deploy-site-task.yml` with `secrets: inherit`. Concurrency is keyed on the environment with `cancel-in-progress: false`, because a cancelled deploy leaves a release uploaded and unflipped. The task pins the generator by version and checksum, and re-asserts the environment name in a job of its own, because the `environment:` binding resolves before any step runs and a `workflow_call` caller is not bound by the dispatch choice list a human sees. Its environment-bound job then: checks out full history (a shallow clone silently changes page metadata), installs the verified generator, derives the release id **once** and exports it (deriving it twice yields ids seconds apart, and the live check then asserts a version nothing installed), builds the tree, installs the deploy credential from the environment, uploads into a per-release directory hard-linked against the current release and carrying **no** delete flag (at an environment root a delete removes the rollback targets), flips the pointer as a separate atomic step so a failed transfer cannot half-publish, and finally checks the running host (D4.6). Retention (D5.6) is bounded by a declared count with one side recorded as owning it: a deploy whose credential can observe the destination prunes and asserts the count here, while a credential confined **write-only** can neither delete nor read back, so there the prune is a host-side timer and the repo's runbook records that ownership. Widening the credential to bring the prune in-pipeline would trade a real confinement boundary for a check, and is the wrong trade. What the guarantee rejects is neither side owning it. One thing the pipeline cannot assert and the server config must: a non-public environment serving a byte-identical copy must not be indexed, and that default belongs on the side that is harmless in production, since a non-public container missing the value is still behind its gate while a production container inheriting it deindexes the site silently. Applicable scenarios: S1 (validation), the source-only release set S7/S8/S9/S10, and S12/S13 (the deploy dispatch). N/A: S2-S4, every registry scenario, and D5.1-D5.4 (the pipeline uploads no workflow artifact at all, so D5.6 is what applies in their place), all recorded N/A, not failed. -- **Operational (workflow model, not a build target).** A `workflowModel: operational` repo layers a direct-commit `develop` onto the **source-only** release shape (above). Two workflows: (1) a **lint/validation** PR workflow feeding the required `Check pull request workflow status job`, built from the generic linters (editorconfig/EOL, markdownlint, cspell, actionlint) plus a domain validator (Home Assistant `hass --script check_config`, `esphome config`, a firmware build), with **no unit tests**; its triggers differ from the `release` model: `push` to `develop` (advisory feedback on the direct-commit path) plus `pull_request` to `branches: [ main, develop ]` (enforced on `main` as the promotion gate, reported but not required on `develop`) plus `workflow_dispatch`. (2) the standard **source-only publisher** on `workflow_dispatch` only (`releaseTrigger: dispatch-only`): NBGV + `version.json` own the tag, and a manual dispatch cuts a GitHub release (tag + source zip + README + LICENSE, via the standalone publisher's inlined `action-gh-release`). **The PR trigger names both branches, and naming `main` alone is a defect.** A set that omits `develop` matches nothing when a PR opens against `develop`, so the validation job never starts, the aggregator never reports, and the PR shows a clean mergeable state with an empty check list, which is the exact output D1.2 forbids. Naming both costs a duplicate run, since a PR-merged change validates once on the PR and again on the push when the merge lands, whatever merge method the repo allows (the operational `develop` ruleset prescribes none). The concurrency group is keyed on the workflow name plus `${{ github.ref }}` (Section 2), and a `pull_request` run's ref is `refs/pull/<n>/merge` where the push run's is `refs/heads/develop`, so the two occupy different groups and neither cancels the other. Pay it. On a lint-only gate that is a couple of runner-minutes, where the alternative, a condition that suppresses the push run, has to tell a merge commit from a direct commit and reintroduces exactly the reasoning the trigger set removes. Applicable scenarios: S1 (validation) on every PR, the promotion PR and a PR into `develop` alike, plus the source-only release set: S7 (tag-only release), S8 (dispatch guard), S9 (no-op republish), S10 (classification). N/A: the auto-publish paths (S5/S6 bot-push and schedule, neither of which an operational repo has) and every build/registry scenario. See the branch-model note in Section 3 and [GOVERNANCE.md "Branching Model"][governance-branching-model]. - -<!-- Workflow --> - -[workflows]: ./.github/workflows/ - -<!-- Repo --> - -[codestyle]: ./CODESTYLE.md -[governance-branching-model]: ./GOVERNANCE.md#branching-model -[governance-operational-repositories]: ./GOVERNANCE.md#operational-repositories -[repo-config-readme]: ./repo-config/README.md +# WORKFLOW.md + +The guide for CI/CD **workflows** (GitHub Actions): a deliberate mixture of code style, architecture, a **behavioral contract** (expected inputs and outputs), and a **test methodology**. Code style lives in [`CODESTYLE.md`][codestyle]. This file is its sibling for everything under [`.github/workflows/`][workflows]. + +Its defining principle: **it describes required outcomes, not a required implementation.** Two repos may implement the same guarantee with different YAML. A workflow is correct when it **satisfies the contract** in section 4 and is **defect-free against the expected inputs and outputs**, not when it matches a reference implementation byte for byte. The conventions in section 2 keep workflows legible. The contract in section 4 is what they must *do*. + +Given this document, an agent must be able to do three things to any project: + +1. **Audit** - statically check the workflows against the conventions (section 2) and the structural facts each guarantee implies (section 5A). +2. **Test** - trace the expected inputs/outputs (section 5B) and, where warranted, drive a live probe (section 5C). +3. **Assess** - render a verdict: **operational** (every *applicable* guarantee holds and every *applicable* scenario's observed output equals the expected) or **not operational** (any mismatch, which is a *defect*, not a style nit). + +> **Canonical scope.** This document is authoritative for the workflow contract and test methodology (sections 3 to 6). The conventions in section 2 and the release policy also live in `GOVERNANCE.md` ("Workflow YAML Conventions" and "Release Model"), which is authoritative where the two overlap. Section 2 restates them so this file reads on its own. On any conflict in that overlap, `GOVERNANCE.md` wins. + +The guarantees are distilled from failures observed in practice and stated as the **failure-mode each prevents**, so the document stays portable to any project. + +## 1. Purpose and How to Use This Document + +- **Contract, not implementation.** Conform to the *outcomes* in section 4. Shape, job names, and file layout may differ between repos, but the input/output behavior may not. +- **Applicability.** A guarantee (or a 5A check, or a 5B scenario) is **applicable** only if the repo contains the construct it governs: a given target, a transfer artifact, a registry push, a wrapper-version source. An item that governs an absent construct is **N/A**: record it as N/A and **exclude it from the verdict**. N/A is never a defect. Section 6 names which items go N/A per project type. A near-empty pipeline (source-only) is mostly N/A and that is fine. +- **Operational is binary.** A workflow is operational only if every *applicable* guarantee holds. A single applicable input/output mismatch is a defect and makes the workflow non-operational, regardless of how clean the YAML looks. +- **Default branch.** Guarantees say "default branch" portably. It is implemented as the literal `main` in several places (the validate gate, the `prerelease` expression, and `version.json`'s `publicReleaseRefSpec`). These MUST all reference the repo's *actual* default branch. A divergence is a defect (section 5A). +- **Two layers when auditing.** The pipeline splits into an **orchestrator** layer (the PR entry workflow, the publisher, and the version/release/badge jobs) and a **build-leaf** layer (`build-<target>-task.yml`). Inputs like `github`/`nuget`/`dockerhub`/`expect_release_assets` live on the orchestrator. A leaf only ever receives `ref`/`branch`/`smoke` (and a derived `push`). When a check names an input, assert it in the layer that declares it. +- **The three verbs.** Audit (static), Test (trace + probe), Assess (verdict). Section 5 gives the exact procedure. + +## 2. Workflow Style Conventions + +Prescriptive style/legibility rules. Cheap to check, necessary but not sufficient (a perfectly styled workflow can still violate section 4). + +- **Action pinning.** Pin **every** action to a commit SHA with a trailing `# vX.Y.Z` comment. Use `# vX` only when the upstream floating major tag has no specific patch SHA. The single documented no-pin exception is a tool whose tag stream lags `master` such that tag-tracking would downgrade (here, `dotnet/nbgv@master`). Invent no others. +- **Filename.** Reusable workflows (`on: workflow_call`) end in `-task.yml`. Entry-point workflows do not (`-pull-request.yml`, `-release.yml`). Lowercase, hyphen-separated. +- **Workflow `name:`.** Reusable names end in **"task"**. Entry-point names end in **"action"**. +- **Job and step `name:`.** Every job ends in **"job"**, every step in **"step"**, including a ruleset-bound required-check job, whose `name:` and the ruleset `context:` are one string renamed together (never independently). +- **Concurrency.** Top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }`. Document exceptions inline (D7). +- **Shells.** Every multi-line bash `run:` (and every committed `.sh` script) starts `set -Eeuo pipefail`. +- **Conditionals.** Multi-line `if:` uses the folded scalar `if: >-`. +- **Boolean inputs.** A boolean used by both `workflow_call` and `workflow_dispatch` is declared in **both** trigger blocks, and `workflow_dispatch` delivers the **string** `"true"`/`"false"`, so any `if:` compares both forms: `${{ inputs.foo == true || inputs.foo == 'true' }}`. +- **Reusable-workflow permissions.** Job-level `permissions:` are validated **before** `if:`, so even a skipped job needs valid permissions. Grant least privilege. A reusable callee's extra scope (e.g. `actions: write` for cleanup) is granted by the **caller**. +- **Allowlist `success` and `skipped` explicitly** across optional dependencies (`!= 'failure'` lets `cancelled` through). +- **Docker layer cache.** Cache to/from a registry tag (`type=registry`), never `type=gha`. +- **Line endings.** Workflow YAML is LF (Actions and Dependabot rewrite it that way). Other files follow `.editorconfig`, and committed JSON state files follow the repo's JSON rule. Preserve endings on every edit. + +## 3. Architecture + +### Branch Model + +Two workflow models, set per repo by the registry `workflowModel` field. `release` (default) is the feature-branch pipeline this document specifies: + +```mermaid +flowchart LR + feature[feature branch] -->|squash| develop + develop -->|merge commit| main + main -.->|no back-merge| develop +``` + +`operational` repos (live-service config, `workflowModel: operational`) commit directly to `develop` and promote a known-good snapshot to `main` via an occasional PR: + +```mermaid +flowchart LR + edit[direct signed commit] -->|advisory CI| develop + pr[pull request] -->|lint CI, reported not required| develop + develop -->|merge commit, enforced lint CI| main +``` + +The direct commit is an **allowance, not a substitute for review**. The ruleset drops the pull-request *requirement*, which permits a direct push without withdrawing the pull request, so a change worth reviewing still takes one and both paths reach `develop` legally. Which changes those are is stated as a shape rather than a line count in [GOVERNANCE.md "Operational Repositories"][governance-operational-repositories], which owns the test and is the one place it is written, since nothing in a ruleset can apply it. What differs is when validation lands. On the direct-commit path the commit is already on the branch, so CI can only be advisory after the fact, and that is the accepted cost of the model. On the pull-request path the change has not landed, so validation is pre-merge and actionable, which is the moment it is worth the most, and the lint workflow's `pull_request` trigger therefore names `develop` alongside `main` (Section 6). That is what makes **D1.2** hold here, since its input is *any* PR and the operational model is no exception. The check is reported on a `develop` PR rather than required, because a required status check on `develop` binds the direct push too and would dissolve the allowance the model is built on. + +Their CI is lint/validation only (editorconfig/EOL plus domain linters such as Home Assistant or ESPHome config validation or a firmware build, but **no unit tests**), so the D-guarantees below that assume a build/test pipeline are **N/A** exactly as for `source-only` (Section 6). What binds: the promotion gate, where the `develop -> main` PR must pass the required `Check pull request workflow status job`, and the source-only release on manual dispatch (`releaseTrigger: dispatch-only`; tag + source zip). Branch-model rulesets are specified in [GOVERNANCE.md "Branching Model"][governance-branching-model] and [repo-config/README.md][repo-config-readme], not here. + +### 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, the date-badge job, and the `changes -> smoke-build -> aggregator` shape of the PR workflow. These job *bodies* should not need per-repo edits. +- **Build** is repo-owned: the `build-<target>-task.yml` leaf tasks. +- **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_<target>` inputs and the `build-<target>` job + its `github-release` `needs:` entry in the release task, **and** the `changes` paths-filter entry + output + the `smoke-build` enable-forward in the PR workflow. "Verbatim" applies to the `github-release` job and the version/publish-plan logic, not 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-<branch>-library` producer needs a new `enable_library` input, a `build-library` job, a `needs:` entry, and a `library` paths-filter). + +### The Seam Contract + +A target contributes a file to the GitHub release by uploading a workflow artifact named `release-asset-<branch>-<target>`. The release job collects **every** matching artifact by **pattern** (`pattern: release-asset-<branch>-*` + `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. + +```mermaid +flowchart LR + leafa[leaf: target A] -->|release-asset-branch-A| store[(run artifacts)] + leafb[leaf: target B] -->|release-asset-branch-B| store + store -->|pattern + merge-multiple| rel["github-release job (D6)"] + reg[registry leaf: nuget / pypi / docker] -->|push, no asset| registries[(registries)] +``` + +### 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. + +### 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<sha>`. `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. + +### Validate-at-Entry + +When a workflow's inputs carry a cross-input or input-versus-derived-state invariant, assert it **once** in a dedicated entry job/step the downstream jobs `needs:`, failing fast with `::error::` before any build or publish. + +### Resource Lifecycle + +Workflow artifacts are an **intra-run handoff** only. Durable copies live on the release/registry. The rule: a transfer artifact handed **between jobs** is deleted by exact name/pattern **at its point of consumption**, the delete is **gated to the same condition as the consumer**, 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, and an intermediate consumed only within the same run (e.g. an executable's per-runtime outputs feeding an aggregation step) may rely on the retention backstop alone. The run is **never** blanket-deleted (`.artifacts[].id`). See D5. + +### Fast PR Feedback + +PRs validate fast and never publish: a paths-filter smoke-builds only changed targets. A validation job always runs. Smoke builds compile/lint/test but upload nothing and push nothing. One required aggregator gates the merge. See D1. + +```mermaid +flowchart TD + pr[pull request] --> ch[changes paths-filter] + ch -->|target changed| sb[smoke-build changed targets] + ch -->|workflow-only or docs| skip[smoke-build skipped] + val[validation job] --> agg["Check pull request workflow status job (D1)"] + sb --> agg + skip --> agg + agg -->|success| ok[merge allowed] +``` + +### Release Model + +Each publish builds a **single branch**, the trigger ref (`main` a release, `develop` a prerelease), so there is no branch matrix and `github.ref` always names the built branch. A **human merge never auto-publishes**: a 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 or push to their own registry. An unchanged version re-pushes nothing (no-op republish). Docker re-pushes by design. + +```mermaid +flowchart TD + trig[main-only schedule / dispatch / paths-filtered push] --> one[build the one trigger branch] + one -->|main| vmain["version X.Y.Z stable (D3)"] + one -->|develop| vdev["version X.Y.Z-g-sha prerelease (D3)"] + vmain --> relm["github-release + registries: latest (D4)"] + vdev --> reld["github-release + registries: prerelease (D4)"] +``` + +### Output Seam by Destination + +Pick each output's path by **where the artifact goes**: + +- **File on the GitHub release** (zip, binary, packaged library): one leaf per output uploading `release-asset-<branch>-<name>`. The repo keeps `expect_release_assets: true` (its default). +- **Package-registry push** (NuGet, PyPI): the leaf builds and publishes to its registry. NuGet pushes from the leaf *and* uploads a `release-asset-*`. PyPI is **split**: the leaf only builds + uploads its build artifact, a separate publish job does the OIDC upload (so `id-token: write` is granted at one entry point, behind an environment gate) and contributes **no** `release-asset-*`. +- **Image-registry push** (Docker): the leaf pushes the default branch multi-arch (amd64+arm64) and any other branch `amd64`-only (arm64 emulation is reserved for the released image), and contributes no `release-asset-*`. +- **Filesystem on a host the project owns** (a static site, a config tree): the leaf builds the tree, ships it to the host, and contributes no `release-asset-*`. The transport is the repo's own. What the contract fixes is that the deploy is a **separate `workflow_dispatch`** from the release, so a redeploy of an unchanged commit mints no tag and a host rebuild, a rollback, or proving a branch on a non-production environment costs nothing; that its credentials come from a **per-environment GitHub Environment** rather than the repository secret store; and that the deploy ends by asserting **what the host serves** rather than the transport's exit status (D4.6). Retention at the destination is bounded by a declared count with one side recorded as owning the prune, which is the deploy where its credential can observe the destination and the host where that credential is deliberately write-only (D5.6). +- **No file target via the release task** (Docker-only, PyPI-only): the release is tag + source zip + README + LICENSE. The repo's **caller MUST pass `expect_release_assets: false`** to the release task (the input is never set by a publisher that ships file targets, which keeps the default `true`). This is the one case where the otherwise-verbatim publisher is edited. With the default `true` and no assets, the release-create step fails on `fail_on_unmatched_files`. A **source-only** repo has no release task at all. Its standalone `publish-release.yml` inlines `action-gh-release`, so `expect_release_assets` does not apply (see Section 6). + +## 4. Behavioral Contract: Expected Outcomes + +The required behaviors, organized by domain. Each is a **MUST**, stated as input -> output plus the failure-mode it prevents. A workflow that violates any *applicable* guarantee is **not operational**. + +### 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). *Prevents: rebuilding everything, and a changed target slipping through unbuilt.* +- **D1.2 A validation job always runs.** Input: any PR. Output: a type-appropriate validation job runs unconditionally and the aggregator `needs:` it. In a .NET repo this is the `unit-test` job (format/style/test). A non-.NET repo **replaces** it (not deletes) with its own validator (lint, schema-check) and re-points **every** `needs:` on it (both the aggregator and `smoke-build`, which `needs:` the validation job by name) to the replacement. *Prevents: a PR merging with no validation, or a dangling `needs:` that fails the whole workflow to load.* +- **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 `!smoke`). *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 excludes workflow files, so smoke-build skips. *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**, `needs:` the changes job and the validation job, 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's validation/test job. Output: tests run with coverage collection (`dotnet test --collect:"XPlat Code Coverage"` or `pytest --cov-report=xml`) and a `codecov/codecov-action` step uploads it, **best-effort** (`continue-on-error` and/or `fail_ci_if_error: false`, so a Codecov outage or an absent token never reds the gate). `CODECOV_TOKEN` lives in the repo's **actions** secret store and reaches the reusable validator via `secrets: inherit`. Required for **every** C# and Python repo that has tests (see `spec/secrets.json` `typeMechanisms`). The repo also ships a **`codecov.yml`** that sets the project and patch statuses to **`informational: true`** so a coverage delta never gates a PR (a distinct knob from `fail_ci_if_error`, which only guards the upload step), and excludes intentionally-untested, non-shipped code (an example/demo or benchmark project) from the coverage denominator via `ignore`. A repo may override this to enforce a coverage threshold where its quality bar requires it. Coverage output is a build artifact, so `.gitignore` excludes it (e.g. `coverage/` and `*.cobertura.xml`, with `.gitignore` the full source of truth) so a blanket `git add -A` won't stage the untracked output. *Prevents: coverage silently going unreported; a stale, unused token; a coverage regression blocking an unrelated PR; a coverage artifact committed by a blanket add.* + +### 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.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.* + +### 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<sha>`. 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<sha>` 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.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.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 is a tag on the built commit plus the auto source zip, README, and LICENSE; file-producing targets attach `release-asset-*`; `prerelease` equals `branch != default`. A no-file-target repo that uses the release task (Docker-only, PyPI-only) reaches the tag-only shape **only** with `expect_release_assets: false` set by the caller (which relaxes `fail_on_unmatched_files` and skips the asset download). With the default `true` and no assets the release-create step fails. A source-only repo reaches the same shape through its inlined `action-gh-release` instead, with no release task or `expect_release_assets`. +- **D4.4 No-op republish.** Input: a re-run whose version is unchanged. Output: nothing is re-pushed, because the release-create step is skipped when the tag exists (refreshed only on `workflow_dispatch`), and the paired asset-delete is skipped with it. 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, so a failed build skips it (no tag, no release), and the terminal registry pusher (Docker) needs every other build and guards its `if` with `!failure() && !cancelled()`, so a failed build skips docker too (no image push) while a disabled or unchanged target (skipped, not failed) still lets docker build on smoke. *Prevents: a partial publish, e.g. a Docker image pushed while the executable build failed and no release was cut.* A repo pushing two registry targets at once would need a build/publish split behind an all-builds gate, which none does today. +- **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 (e.g. an executable's per-runtime outputs feeding an in-run aggregation) MAY instead rely on the `retention-days: 1` backstop. *Prevents: transfer artifacts accumulating against the storage quota.* +- **D5.2 Gate the delete to the consumer's condition.** Output: the delete runs under the **same** condition as its consuming step. Where the consumer is conditional (the GitHub release create), the delete is conditional too. Where the consumer always runs when its job runs (the PyPI publish step), the delete always runs, so on a no-op re-run the `release-asset-*` delete is **skipped** while the PyPI build-artifact delete still **runs** (its publish ran). *Prevents: deleting freshly built assets on a no-op re-run.* +- **D5.3 Best-effort.** Output: cleanup is `continue-on-error`, tolerates a failed listing, and deletes **all** matching ids. *Prevents: a cleanup hiccup reddening a job whose publish succeeded.* +- **D5.4 Retention backstop.** Output: **every** `upload-artifact` sets `retention-days: 1`. +- **D5.5 Never blanket-delete.** Output: cleanup MUST NOT enumerate and delete the run's whole artifact set. *Prevents: destroying diagnostic/log artifacts and auto-emitted build-records.* +- **D5.6 A durable destination's retention is bounded and owned.** Input: a deploy that installs a release beside the retained ones on a host the project owns. Output: retention is bounded by a **declared count**, and the side owning the prune is **written down**. Where the deploy credential can observe the destination, the deploy asserts the count converged and fails when it does not. Where the credential is deliberately write-only, so it can neither delete nor read back, the prune belongs to the **host** and that ownership is recorded there: widening the credential to reach the destination would trade a real confinement boundary for a check, which is the wrong trade. The release the live pointer resolves to is never a prune candidate, whatever the sort order says. A prune that runs against a local scratch tree, or that is best-effort, or that no side is recorded as owning, satisfies none of this. Unlike D5.1 through D5.4, this destination is durable rather than a run-scoped artifact, so no retention backstop expires it. *Prevents: a destination growing without bound until the disk fills, which surfaces as a site outage rather than as a failed deploy; and the split-ownership version of the same, where each side assumes the other prunes.* + +### D6 - Seam / Architecture Conformance + +- **D6.1 Pattern handoff.** Output: the release job downloads by `pattern:`/`merge-multiple:`, not `artifact-ids:`. Targets upload `release-asset-<branch>-<target>`. Canonical for single-target. +- **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_<target>` input, the `build-<target>` job and its `github-release` `needs:` entry, the `changes` paths-filter entry + output, and the `smoke-build` enable-forward (and, for PyPI, the separate `publish-pypi` job). The `github-release` job body stays verbatim. *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 Skipped jobs still need valid permissions.** Output: every reusable job declares valid `permissions:`. 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. +- **D7.3 Boolean inputs both forms.** Output: declared in both trigger blocks, compared against `true` and `'true'`. +- **D7.4 Optional-dependency chaining.** Output: cross-job conditions allowlist `success`/`skipped` explicitly. + +### D8 - Bots / Automation + +- **D8.1 Merge-bot.** Output: enables auto-merge on `opened`/`reopened` for **every** Dependabot tier including semver-major (the required checks are the gate, not the bump magnitude); dispatches `--squash`/`--merge` by the PR's base ref; disables on a maintainer-pushed `synchronize`; concurrency keyed on the **PR number**, not `github.ref`. *Prevents: two PRs colliding in auto-merge.* +- **D8.2 CodeGen and Dependabot.** Output: codegen runs as a matrix over both branches and is deterministic from an external source. Dependabot targets both branches, security PRs to default. +- **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it. The `main` pin push publishes via the release gate, while a `develop` pin does not auto-publish. It ships via a `develop` dispatch (prerelease) or the next promotion to `main`. The tracker's `bump-branch-prefix` + `branches` MUST match a merge-bot rule, one of the built-in `<prefix>-<base>` head/base pairs or a `rules` entry the caller passes, or auto-merge silently never fires. +- **D8.4 An identity allowlist used as a gate fails loud.** Where a gate compares `github.actor` (or a PR author) against hard-coded bot identities, the non-matching branch on an otherwise-legitimate trigger **emits a `::warning::`** rather than falling through silently. Output: a run that declines to act on an unrecognized identity is visibly annotated. *Prevents: the App being renamed, replaced, or reinstalled under a new slug, after which the comparison quietly evaluates false and the gate stops firing, a green and silent run that looks identical to a healthy one.* The masking matters most where a second path hides the loss: a weekly schedule keeps publishing, so the only symptom is release *timeliness*, easily missed for months. Where the failure is self-announcing instead (the merge-bot simply stops merging, so bot PRs visibly pile up) an annotation is optional. Resolving the identity at run time (mint an App token, read `GET /app`) removes the hard-coded string entirely and is the escalation if an allowlist proves fragile in practice. + +### D9 - Style / Static (See Section 2) + +- **D9.1** Every action SHA-pinned with a version comment (sole exception: the documented lagging-tag tool). +- **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.4** Docker layer cache targets a registry tag, not `type=gha`; `cache-to` writes only the built branch's `buildcache-<branch>` and only on push, while `cache-from` reads both branches; multi-image repos use a per-image cache tag. +- **D9.5** Line endings follow `.editorconfig`. + +## 5. Test Methodology + +An agent verifies a project in three escalating modes, then renders a verdict. **Skip N/A items** (section 1): a check or scenario for an absent construct is recorded N/A, not failed. + +### 5A. Static Audit (No Execution) + +Read the workflow files plus `version.json` and assert the structural fact behind each *applicable* D-guarantee, each pass/fail/N-A with a `file:line` citation. Remember the two layers: assert each input in the file that declares it. + +**Core (every repo):** + +- **D1:** a `changes` paths-filter job exists, covers each of the repo's targets, and **excludes** `.github/workflows/**`; the PR entry workflow's smoke call sets `github/nuget/dockerhub: false` on the release task; the leaf receives `smoke: true` and a derived `push` (false on smoke); every build-task `upload-artifact` (and any aggregation job) is gated `!smoke`; the aggregator `needs:` the `changes` and validation jobs, blocks on `failure`/`cancelled`, passes on `skipped`; a validation job runs unconditionally. +- **D2:** an entry validation job/step exists per complex-input workflow; the release gate checks both directions, strips `+buildmetadata`, and skips on smoke; the publisher rejects a dispatch from a ref other than `main` or `develop`. +- **D3:** each run builds one branch, so NBGV classifies `github.ref` directly (no `IGNORE_GITHUB_REF`), and the default-branch literal in the gate (`== 'main'`), the `prerelease` expression (`!= 'main'`), and `version.json`'s `publicReleaseRefSpec` all name the repo's actual default branch. +- **D4:** `target_commitish` is the NBGV commit id; `prerelease` equals `branch != default`; the release-create step is gated `exists == 'false' || github.event_name == 'workflow_dispatch'` (the step output is the string `'false'`, not a boolean); the asset-delete step is gated identically. A dispatch-only publisher (`releaseTrigger: dispatch-only`) may omit the gate and the exists-check entirely: every run is a dispatch, so the skip leg can never fire and create-or-refresh is unconditional. Record the gate N/A there, not missing. +- **D5:** each cross-job transfer artifact has a delete step at its consumer, gated to the consumer's condition, `continue-on-error: true`, looping all ids; **every** upload sets `retention-days: 1`; **no** `.artifacts[].id` blanket delete exists anywhere. +- **D6:** the release download uses `pattern:`/`merge-multiple:` (no `artifact-ids:`). Branch-derived config reads `inputs.branch` (a `github.ref_name` in such config is a finding). Artifact names are branch-suffixed. The target set is consistent across the release task and the paths-filter. +- **D7:** the publisher concurrency group is ref-independent with `cancel-in-progress: false`. Reusable jobs declare permissions. Boolean `if:` uses both forms. +- **D8/D9:** merge-bot concurrency keys on PR number. The upstream tracker's branch prefix matches a merge-bot rule (wrapper repos). Actions are SHA-pinned. Names/shells/conditionals follow section 2. + +**Per-type addenda (apply only the ones present):** + +- **Console/executable:** the smoke runtime matrix is a strict non-empty subset of the full matrix. The per-runtime outputs (`publish-<branch>-<runtime>`) are aggregated by `pattern:` + `merge-multiple:` into one `release-asset-<branch>-<target>` and the aggregation job is gated `!smoke`. The per-runtime intermediates rely on the retention backstop (no explicit delete is required for an in-run intermediate). +- **NuGet:** the publish step is gated `if: inputs.push` only (not on an existence check) and uses `--skip-duplicate`. `*.nupkg` push also carries the paired `.snupkg` to the symbol server where symbols are enabled. The `release-asset` zip carries the package(s). +- **PyPI:** `publish-pypi` declares `environment: { name: pypi }`. `id-token: write` appears only on that job (absent from the build/PR path). `skip-existing: true` is set on the publish action. The build artifact is deleted after publish. The `pypi` environment has a deployment-branch rule. +- **Docker:** a Docker-only repo's caller passes `expect_release_assets: false`. The leaf reads the external state file for the tag instead of `SemVer2` (wrapper repos only, since a plain Docker repo correctly tags off `SemVer2` and records this N/A). The readme/date-badge jobs are gated main-only. The docker-readme task validates `repositories` XOR `manifest`+`manifest-jq`. The buildcache follows D9.4. +- **Static site deployed to a host:** the generator is pinned by version **and** by a checksum verified before install, declared once across the workflows that install it. The deploy is a dispatch carrying an environment choice, with concurrency keyed on the **environment** and `cancel-in-progress: false`, and production gated to the default branch while any ref may reach a non-production environment. The reusable callee re-asserts the environment name in a job of its own. The upload targets a per-release directory and carries no delete flag at the environment root, and the pointer flip is a separate step. The terminal check asserts the golden-list length floors first, then the environment, then the release id, then the URL contract. Retention is bounded by a declared count and one side is recorded as owning the prune: the deploy asserts it where the credential can observe the destination, and the host owns it where the credential is confined write-only (D5.6). + +### 5B. End-to-End Trace Scenarios (No Execution, Deterministic from the YAML) + +For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the inputs and emit the predicted **run/skip + version + release + artifact-end-state** table, then compare to the expected. Scenarios that exercise an absent target are N/A. Minimum set: + +| # | Input | Expected output | Exercises | +| --- | --- | --- | --- | +| S1 | PR touching a build target | `changes` flags it; validation runs; that target's smoke build runs; no push, **no uploads**; validate-release **skipped (smoke), succeeds**; release **skipped**; aggregator **success**; version = prerelease; no release; no dangling artifacts | D1, D2.2, D3 | +| S2 | PR changing only docs | smoke-build **skipped**, validation runs, aggregator **success** | D1.1, D1.5 | +| S3 | PR changing only `.github/workflows/**` | filter excludes -> smoke-build **skipped**, aggregator **success** | D1.4 | +| S4 | PR base = default branch, carrying a build target | smoke versions as prerelease, validate-release **skipped (smoke)** so the default-branch arm does **not** fire, aggregator **success**, promotion not blocked | D1.3, D2.2 | +| S5 | bot push to `main` not touching a release path (e.g. an Actions bump) | the paths filter excludes it, so nothing publishes | D4.1 | +| S6 | code-affecting **bot** push to `main` (a human push/promotion, or any develop push, does not) | the `plan` job gates it to the App/Dependabot actor, and `main` publishes a release | D3, D4 | +| S7 | publish run (schedule, a bot push to main, or a dispatch) | builds the **one** trigger branch: `main` -> `X.Y.Z`, `prerelease=false`, registry stable, badge/readme run; `develop` -> `X.Y.Z-g<sha>`, `prerelease=true`, registry prerelease; `release-asset-*` consumed-then-deleted; PyPI build-artifact deleted after its publish; **no dangling artifacts** | D3, D4, D5, D6, D7 | +| S8 | dispatch from a ref other than `main` or `develop` | **fails fast** | D2.3 | +| S9 | re-run publish, version unchanged | release-create **skipped**, `release-asset-*` delete **skipped**; NuGet/PyPI pushes no-op (server dedupe); **PyPI build-artifact still deleted** (its publish ran); **Docker still re-pushes** the image; no duplicate release | D4.4, D5.2 | +| S10 | branch/version classification disagree | validate-release **fails loud**, build/publish skip | D2.2 | +| S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a `<prefix>-<branch>` PR -> merge-bot auto-merges -> the `main` pin publishes via the gate (a develop pin does not auto-publish, shipping instead via a develop dispatch or promotion) | D8.3, D3.5 | +| S12 | deploy dispatch naming an environment | the ref gate runs **first** (production from the default branch only, any ref to a non-production environment); validation runs; the callee re-asserts the environment name; a release installs under its own id; the pointer flips as a separate step; retention is bounded by whichever of the two D5.6 shapes the repo uses, so a deploy whose credential can observe the destination asserts the count converged and one confined write-only leaves it to the host; the live check asserts the environment and the release id, waiting out the reload, then the URL contract; **no tag and no release are created** | D2.1, D4.6, D5.6, D7.1 | +| S13 | deploy dispatch of a production environment from a non-default ref | **fails fast**, before anything is installed or written | D2.1, D2.3 | + +### 5C. Live Probe (Where Warranted) + +- Open a trivial-change PR touching one target and confirm S1. +- Drive a `smoke: true` push-probe of the build task for **both** the default and a non-default branch and assert the version classification (clean vs prerelease) and that the gate passes, **without publishing**. *Caveat: the Docker leg logs in to the registry even on smoke and reads the buildcache, so it needs `DOCKER_HUB_*` secrets and cannot run on a fork PR (same-repo only).* +- Per registry: after a real publish, query NuGet.org for the expected version + prerelease classification (and the `.snupkg` on the symbol server), and confirm a re-run added no duplicate. For PyPI inspect the `Compute PyPI version step` log and the built `dist/*` filenames for `.dev0` off `develop` vs a plain version on the default branch. +- Inspect the latest real publish's logs for `PublicRelease`/`SemVer2` per leg and confirm the artifact lifecycle (uploaded, consumed, deleted, with none left behind). +- **The deploy ref gate (S13) is verified only by tripping it, and the dispatch that trips it is the maintainer's to run.** Dispatch the production environment from a non-default ref and expect the run to fail at the gate. The evidence is four things, and each of them matters: the gate job's conclusion, its error text naming the expected and the received ref, every downstream job recorded as **skipped** rather than passed, and the deployment count against the production environment unchanged. Capture all four, because a gate that fails open and a gate nobody tripped produce the same empty run history, so "we have never seen it fail" is not evidence about the one control standing between a mis-dispatch and the live site. **The agent prepares the command and reads all four back afterwards. It does not fire it.** An agent harness may refuse to dispatch a production deploy, which is the harness working as intended, and the refusal is neither re-shaped into a raw API call nor talked around (GOVERNANCE.md "Repository Boundaries and Write Safety"). The same split applies to any probe that acts on the deploy host directly, an outbound SSH exercising a forced command among them. + +### Assessment + +The workflow is **operational** iff every *applicable* 5A item passes and every *applicable* 5B scenario's observed output equals the expected (confirmed by 5C where a live signal exists). N/A items are excluded, never counted as failures. Any *applicable* mismatch is a **defect** -> **not operational**. Procedure: + +1. **Audit** with 5A. Record pass/fail/N-A with `file:line`. +2. **Trace** the applicable S-scenarios with 5B. Diff predicted vs expected. +3. **Probe** with 5C only for guarantees a static trace cannot settle (live version classification, registry state, artifact lifecycle). +4. **Verdict:** operational / not operational, with the failing guarantee(s) and the triggering input for each, and the list of items recorded N/A. + +## 6. Per-Project-Type Test Walkthroughs + +Each type maps the *applicable* S-scenarios onto its targets. The differences are which leaf tasks exist and what each produces, which 5A addenda apply, and which scenarios are N/A. Walking these is the self-check that the contract holds for each shape. + +- **Console / executable application.** Target produces `release-asset-<branch>-executable` (a 7z archive, `Console.7z`) by building a per-runtime `dotnet publish` matrix, then an aggregation job downloads the per-runtime `publish-<branch>-<runtime>` intermediates (`pattern:` + `merge-multiple:`), zips them, and uploads the single asset. Smoke builds a strict subset of runtimes. The per-runtime upload **and** the aggregation job are both gated `!smoke`, so smoke uploads nothing. The per-runtime intermediates rely on `retention-days: 1` (no explicit delete). Test: S1 with a console change smoke-builds the subset and uploads nothing; S7 attaches the 7z, `prerelease=true` on the non-default leg and `prerelease=false` on the default leg (GitHub auto-marks the stable default release "Latest", and the workflow does not set it). +- **NuGet library.** The leaf both pushes (`dotnet nuget push *.nupkg --skip-duplicate`, gated `if: push` only) and uploads `release-asset-<branch>-nugetlibrary`. Configuration is Release on the default branch, Debug otherwise. Where symbols are enabled (`snupkg`), the push auto-carries the paired `.snupkg` to NuGet.org's symbol server and the asset zip also contains it, a triple surface. NuGet.org derives `isPrerelease` from the SemVer2 `-g<sha>` suffix (the workflow sets no such flag). Test: S7 non-default leg publishes a prerelease package + asset, default a stable; S9 re-run is a server-side `--skip-duplicate` no-op. 5C: query NuGet.org for both versions and the symbol package. +- **PyPI library.** The leaf builds + uploads `pypilibrary-build-<branch>`. A **separate** `publish-pypi` job (with `environment: pypi`, `id-token: write`, `actions: write`) does the OIDC Trusted-Publishing upload with `skip-existing: true`, then **consume-then-deletes** the build artifact, **unconditionally on consume**, so on S9 it is deleted even though the `release-asset-*` delete is skipped. The version is `AssemblyFileVersion` with `.dev0` appended on `develop` only, and must stay `--pre`-selectable and sorted above the default release. PyPI contributes no `release-asset-*`. A PyPI-only repo sets `expect_release_assets: false` at the caller. Test: S7 default leg publishes a release, non-default a `.dev0`; S9 is a `skip-existing` no-op; 5C inspects the `dist/*` filenames and the compute-version log. +- **Docker image.** The leaf pushes the default branch multi-arch (amd64+arm64) and any other branch `amd64`-only, with a per-branch registry buildcache (`buildcache-<branch>`; a multi-image repo adds a per-image tag) (`cache-to` only the built branch and only on push, `cache-from` both branches); no `release-asset-*`, so a Docker-only repo's caller passes `expect_release_assets: false`; the readme (`peter-evans/dockerhub-description`, `DOCKER_HUB_ACCESS_TOKEN`) and date-badge jobs run **only** when the default branch publishes; the docker-readme task validates `repositories` XOR `manifest`+`manifest-jq` and a multi-image repo derives its publish matrix from the manifest. Docker **always re-pushes** the image, independently of a skipped release-create (S9). A **wrapper** repo tracks an upstream release: the upstream tracker writes a `name -> version` state file and the merge-bot auto-merges the bump PR (S11), and the leaf MUST read that file for the immutable tag instead of `SemVer2` (the tracker ships without this consumer wiring). Test: S7 default leg pushes `latest` + the version tag and updates readme/badge. Non-default pushes the develop tag (amd64 only). S9 still re-pushes. S11 ships the bumped upstream version next publish. 5C Docker probe needs `DOCKER_HUB_*` secrets and same-repo (not fork) runs. +- **Data / asset library.** A single new leaf: validate -> zip -> upload `release-asset-<branch>-library` (`retention-days: 1`, upload gated `!smoke`, mirroring the nugetlibrary leaf's shape). Because no such leaf ships, you **add a target** (D6.4): a new `enable_library` input + `build-library` job + `github-release` `needs:` entry in the release task, and a `library` paths-filter entry + `changes` output + `smoke-build` enable-forward in the PR workflow (without it, D1.1 never smoke-builds the library). Keep `expect_release_assets: true` (it has a file target, unlike Docker). The .NET `unit-test` job is replaced by a type-appropriate validator with the aggregator **and** `smoke-build` both re-pointed to it (D1.2/D1.5). `version.json` + the NBGV `get-version` step are retained (they own the tag). Test: S1 smoke runs validate+zip and uploads nothing; S7 attaches the zip, prerelease on the non-default leg; S9 on a *scheduled* re-run release-create + asset-delete skip (the existing zip is untouched, no registry push), while a `workflow_dispatch` re-run **refreshes** the release and re-runs the asset-delete (the asset is re-uploaded then re-deleted). N/A: the nuget/pypi/docker/executable 5A addenda and their scenario clauses. +- **Source-only / no build.** There is no `build-release-task.yml` (its `appliesTo` excludes source-only) and no package/image leaf, so nothing is edited down. The release is a standalone dispatch-only `publish-release.yml` that inlines NBGV for the tag and `action-gh-release` for the release: tag + source zip + README + LICENSE, with no reusable release task and no asset download. With no target the paths-filter matches nothing, so `smoke-build` is **structurally always skipped**, and validation is carried solely by the (replaced, non-.NET) validation job that the aggregator and `smoke-build`'s own `needs:` must both point at (D1.2, or drop the never-running `smoke-build` job). NBGV and `version.json` are still retained (they own the tag). Its publish job gates on the repo's reusable validation task (`needs:` the same `workflow_call` job the PR workflow runs), so a dispatch cannot release a ref that fails validation. Applicable scenarios: S1 (validation only), S5/S6 (publish gating), S7 (tag-only release), S8 (dispatch guard), S9 (no-op republish), S10 (classification gate). N/A: S2-S4 (assume a smoke-built target), the artifact-lifecycle and registry clauses of S7/S9, the D5/D6 artifact items, and all per-type 5A addenda, all recorded N/A, not failed. +- **Static site deployed to a host the project owns.** Two independent surfaces, and keeping them apart is the point. The **release** is the source-only shape above, unchanged: a dispatch-only `publish-release.yml` where NBGV and `version.json` own the tag, producing tag + source zip + README + LICENSE. The **deploy** is its own `workflow_dispatch` carrying an `environment` choice input, so redeploying an unchanged commit mints no tag, which matters because redeploying is routine. It runs a ref gate **first**, before anything is installed or written (production from the default branch only, while any ref may reach a non-production environment, since proving a branch before it merges is what that environment is for), then the **same** reusable validation task the PR gate runs, so a dispatch cannot deploy a ref that fails validation, then calls `deploy-site-task.yml` with `secrets: inherit`. Concurrency is keyed on the environment with `cancel-in-progress: false`, because a cancelled deploy leaves a release uploaded and unflipped. The task pins the generator by version and checksum, and re-asserts the environment name in a job of its own, because the `environment:` binding resolves before any step runs and a `workflow_call` caller is not bound by the dispatch choice list a human sees. Its environment-bound job then: checks out full history (a shallow clone silently changes page metadata), installs the verified generator, derives the release id **once** and exports it (deriving it twice yields ids seconds apart, and the live check then asserts a version nothing installed), builds the tree, installs the deploy credential from the environment, uploads into a per-release directory hard-linked against the current release and carrying **no** delete flag (at an environment root a delete removes the rollback targets), flips the pointer as a separate atomic step so a failed transfer cannot half-publish, and finally checks the running host (D4.6). Retention (D5.6) is bounded by a declared count with one side recorded as owning it: a deploy whose credential can observe the destination prunes and asserts the count here, while a credential confined **write-only** can neither delete nor read back, so there the prune is a host-side timer and the repo's runbook records that ownership. Widening the credential to bring the prune in-pipeline would trade a real confinement boundary for a check, and is the wrong trade. What the guarantee rejects is neither side owning it. One thing the pipeline cannot assert and the server config must: a non-public environment serving a byte-identical copy must not be indexed, and that default belongs on the side that is harmless in production, since a non-public container missing the value is still behind its gate while a production container inheriting it deindexes the site silently. Applicable scenarios: S1 (validation), the source-only release set S7/S8/S9/S10, and S12/S13 (the deploy dispatch). N/A: S2-S4, every registry scenario, and D5.1-D5.4 (the pipeline uploads no workflow artifact at all, so D5.6 is what applies in their place), all recorded N/A, not failed. +- **Operational (workflow model, not a build target).** A `workflowModel: operational` repo layers a direct-commit `develop` onto the **source-only** release shape (above). Two workflows: (1) a **lint/validation** PR workflow feeding the required `Check pull request workflow status job`, built from the generic linters (editorconfig/EOL, markdownlint, cspell, actionlint) plus a domain validator (Home Assistant `hass --script check_config`, `esphome config`, a firmware build), with **no unit tests**; its triggers differ from the `release` model: `push` to `develop` (advisory feedback on the direct-commit path) plus `pull_request` to `branches: [ main, develop ]` (enforced on `main` as the promotion gate, reported but not required on `develop`) plus `workflow_dispatch`. (2) the standard **source-only publisher** on `workflow_dispatch` only (`releaseTrigger: dispatch-only`): NBGV + `version.json` own the tag, and a manual dispatch cuts a GitHub release (tag + source zip + README + LICENSE, via the standalone publisher's inlined `action-gh-release`). **The PR trigger names both branches, and naming `main` alone is a defect.** A set that omits `develop` matches nothing when a PR opens against `develop`, so the validation job never starts, the aggregator never reports, and the PR shows a clean mergeable state with an empty check list, which is the exact output D1.2 forbids. Naming both costs a duplicate run, since a PR-merged change validates once on the PR and again on the push when the merge lands, whatever merge method the repo allows (the operational `develop` ruleset prescribes none). The concurrency group is keyed on the workflow name plus `${{ github.ref }}` (Section 2), and a `pull_request` run's ref is `refs/pull/<n>/merge` where the push run's is `refs/heads/develop`, so the two occupy different groups and neither cancels the other. Pay it. On a lint-only gate that is a couple of runner-minutes, where the alternative, a condition that suppresses the push run, has to tell a merge commit from a direct commit and reintroduces exactly the reasoning the trigger set removes. Applicable scenarios: S1 (validation) on every PR, the promotion PR and a PR into `develop` alike, plus the source-only release set: S7 (tag-only release), S8 (dispatch guard), S9 (no-op republish), S10 (classification). N/A: the auto-publish paths (S5/S6 bot-push and schedule, neither of which an operational repo has) and every build/registry scenario. See the branch-model note in Section 3 and [GOVERNANCE.md "Branching Model"][governance-branching-model]. + +<!-- Workflow --> + +[workflows]: ./.github/workflows/ + +<!-- Repo --> + +[codestyle]: ./CODESTYLE.md +[governance-branching-model]: ./GOVERNANCE.md#branching-model +[governance-operational-repositories]: ./GOVERNANCE.md#operational-repositories +[repo-config-readme]: ./repo-config/README.md diff --git a/catalog/README.md b/catalog/README.md index 933bc7aa..940689d9 100644 --- a/catalog/README.md +++ b/catalog/README.md @@ -1,9 +1,9 @@ -# Catalog - -Reusable reference snippets: concrete config artifacts a repo can copy or compare against. These are -**reference material, not run here**. This repo ships no build, so the workflow snippets below are not active. Each is the canonical shape the audit (`AUDIT.md`) checks a downstream implementation against. - -- `snippets/workflows/`: the reusable build/publish workflow tasks that a code-shipping repo runs (this docs repo keeps only the source-only orchestrator set in `.github/workflows/`). See `snippets/workflows/README.md` for the mapping from each file to the `WORKFLOW.md` guarantees it implements. -- `snippets/configs/`: the config exemplars `vscode-tasks.json` (.NET clean-compile task group) and `vscode-tasks-python.json` (the Python equivalent, running `ruff`/type-check/`pytest`, all `type: process` so no `&&` chaining breaks Windows PowerShell 5.1), plus `dependabot.yml` (multi-ecosystem dual-target reference), `docker-hub-readme.md` (the size-limited Docker Hub overview, distinct from the project `README.md`). -- `snippets/devcontainer/`: `.devcontainer` definitions for the .NET and Python toolchains. -- `snippets/vscode/`: the composable `.code-workspace` fragments: `base.jsonc` (standard set) plus `dotnet.jsonc`, `python.jsonc`, `docker.jsonc` per-type additions. See `snippets/vscode/README.md`. +# Catalog + +Reusable reference snippets: concrete config artifacts a repo can copy or compare against. These are +**reference material, not run here**. This repo ships no build, so the workflow snippets below are not active. Each is the canonical shape the audit (`AUDIT.md`) checks a downstream implementation against. + +- `snippets/workflows/`: the reusable build/publish workflow tasks that a code-shipping repo runs (this docs repo keeps only the source-only orchestrator set in `.github/workflows/`). See `snippets/workflows/README.md` for the mapping from each file to the `WORKFLOW.md` guarantees it implements. +- `snippets/configs/`: the config exemplars `vscode-tasks.json` (.NET clean-compile task group) and `vscode-tasks-python.json` (the Python equivalent, running `ruff`/type-check/`pytest`, all `type: process` so no `&&` chaining breaks Windows PowerShell 5.1), plus `dependabot.yml` (multi-ecosystem dual-target reference), `docker-hub-readme.md` (the size-limited Docker Hub overview, distinct from the project `README.md`). +- `snippets/devcontainer/`: `.devcontainer` definitions for the .NET and Python toolchains. +- `snippets/vscode/`: the composable `.code-workspace` fragments: `base.jsonc` (standard set) plus `dotnet.jsonc`, `python.jsonc`, `docker.jsonc` per-type additions. See `snippets/vscode/README.md`. diff --git a/catalog/snippets/configs/codecov.yml b/catalog/snippets/configs/codecov.yml index 50cbf1c2..0659f87d 100644 --- a/catalog/snippets/configs/codecov.yml +++ b/catalog/snippets/configs/codecov.yml @@ -1,18 +1,18 @@ -# Codecov configuration. -# Codecov's coverage statuses are advisory: they report and trend coverage, never gating a PR. -# - informational: true makes project and patch statuses advisory (always pass), so a coverage delta never blocks a PR. -# - Distinct from the upload step's fail_ci_if_error: false, which only guards upload errors. -# - A repo may override this to enforce a coverage threshold. -coverage: - status: - project: - default: - informational: true - patch: - default: - informational: true - -# Exclude code intentionally not unit-tested from the coverage denominator. -# Add the repo's own paths. -# ignore: -# - "Sandbox/**" +# Codecov configuration. +# Codecov's coverage statuses are advisory: they report and trend coverage, never gating a PR. +# - informational: true makes project and patch statuses advisory (always pass), so a coverage delta never blocks a PR. +# - Distinct from the upload step's fail_ci_if_error: false, which only guards upload errors. +# - A repo may override this to enforce a coverage threshold. +coverage: + status: + project: + default: + informational: true + patch: + default: + informational: true + +# Exclude code intentionally not unit-tested from the coverage denominator. +# Add the repo's own paths. +# ignore: +# - "Sandbox/**" diff --git a/catalog/snippets/configs/dependabot.yml b/catalog/snippets/configs/dependabot.yml index ca644152..1e15d0c7 100644 --- a/catalog/snippets/configs/dependabot.yml +++ b/catalog/snippets/configs/dependabot.yml @@ -1,75 +1,75 @@ -# Reference: multi-ecosystem dual-target Dependabot config for a code-shipping repo. -# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file -# -# Every ecosystem appears twice, once per target branch (main and develop). -# Both branches therefore stay current on dependency versions independently of the release cadence. -# Push-distribution channels (HACS, distros that pull main) consume main directly. -# The main branch must therefore get bumps without waiting for the next release. -# The merge-bot dispatches the merge method per base ref (squash on develop, merge on main). -# Because develop is strictly forward-only, each branch absorbs its own PRs. -# Security (CVE) PRs always target the default branch regardless of target-branch. -# Keep only the ecosystems the repo actually ships. -version: 2 -updates: - - - package-ecosystem: "nuget" - target-branch: "main" - directory: "/" - schedule: - interval: "daily" - groups: - nuget-deps: - patterns: - - "*" - - - package-ecosystem: "nuget" - target-branch: "develop" - directory: "/" - schedule: - interval: "daily" - groups: - nuget-deps: - patterns: - - "*" - - - package-ecosystem: "github-actions" - target-branch: "main" - directory: "/" - schedule: - interval: "daily" - groups: - actions-deps: - patterns: - - "*" - - - package-ecosystem: "github-actions" - target-branch: "develop" - directory: "/" - schedule: - interval: "daily" - groups: - actions-deps: - patterns: - - "*" - - # The uv directory points at the Python package root (e.g. /PackageName). - # Adjust it per repo layout. - - package-ecosystem: "uv" - target-branch: "main" - directory: "/PythonPackage" - schedule: - interval: "daily" - groups: - pypi-deps: - patterns: - - "*" - - - package-ecosystem: "uv" - target-branch: "develop" - directory: "/PythonPackage" - schedule: - interval: "daily" - groups: - pypi-deps: - patterns: - - "*" +# Reference: multi-ecosystem dual-target Dependabot config for a code-shipping repo. +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +# +# Every ecosystem appears twice, once per target branch (main and develop). +# Both branches therefore stay current on dependency versions independently of the release cadence. +# Push-distribution channels (HACS, distros that pull main) consume main directly. +# The main branch must therefore get bumps without waiting for the next release. +# The merge-bot dispatches the merge method per base ref (squash on develop, merge on main). +# Because develop is strictly forward-only, each branch absorbs its own PRs. +# Security (CVE) PRs always target the default branch regardless of target-branch. +# Keep only the ecosystems the repo actually ships. +version: 2 +updates: + + - package-ecosystem: "nuget" + target-branch: "main" + directory: "/" + schedule: + interval: "daily" + groups: + nuget-deps: + patterns: + - "*" + + - package-ecosystem: "nuget" + target-branch: "develop" + directory: "/" + schedule: + interval: "daily" + groups: + nuget-deps: + patterns: + - "*" + + - package-ecosystem: "github-actions" + target-branch: "main" + directory: "/" + schedule: + interval: "daily" + groups: + actions-deps: + patterns: + - "*" + + - package-ecosystem: "github-actions" + target-branch: "develop" + directory: "/" + schedule: + interval: "daily" + groups: + actions-deps: + patterns: + - "*" + + # The uv directory points at the Python package root (e.g. /PackageName). + # Adjust it per repo layout. + - package-ecosystem: "uv" + target-branch: "main" + directory: "/PythonPackage" + schedule: + interval: "daily" + groups: + pypi-deps: + patterns: + - "*" + + - package-ecosystem: "uv" + target-branch: "develop" + directory: "/PythonPackage" + schedule: + interval: "daily" + groups: + pypi-deps: + patterns: + - "*" diff --git a/catalog/snippets/configs/docker-hub-readme.md b/catalog/snippets/configs/docker-hub-readme.md index 3c4c7da4..a8c40773 100644 --- a/catalog/snippets/configs/docker-hub-readme.md +++ b/catalog/snippets/configs/docker-hub-readme.md @@ -1,27 +1,27 @@ -# ProjectTemplate - -Container image for the ProjectTemplate Console app. - -## Image Tags - -- **`latest`** - the current stable release, built from `main`. -- **`develop`** - the current prerelease, built from `develop`. -- Immutable version tags (NBGV SemVer2): `X.Y.Z` for stable releases, `X.Y.Z-g<sha>` for develop prereleases. - -## Usage - -```sh -docker run --rm docker.io/ptr727/projecttemplate:latest -``` - -## Links - -- Source, issues, and CI/CD: [GitHub][projecttemplate-link] -- Versioned releases: [GitHub Releases][releases-link] -- License: [MIT][license-link] - -<!-- External --> - -[license-link]: https://github.com/ptr727/ProjectTemplate/blob/main/LICENSE -[projecttemplate-link]: https://github.com/ptr727/ProjectTemplate -[releases-link]: https://github.com/ptr727/ProjectTemplate/releases +# ProjectTemplate + +Container image for the ProjectTemplate Console app. + +## Image Tags + +- **`latest`** - the current stable release, built from `main`. +- **`develop`** - the current prerelease, built from `develop`. +- Immutable version tags (NBGV SemVer2): `X.Y.Z` for stable releases, `X.Y.Z-g<sha>` for develop prereleases. + +## Usage + +```sh +docker run --rm docker.io/ptr727/projecttemplate:latest +``` + +## Links + +- Source, issues, and CI/CD: [GitHub][projecttemplate-link] +- Versioned releases: [GitHub Releases][releases-link] +- License: [MIT][license-link] + +<!-- External --> + +[license-link]: https://github.com/ptr727/ProjectTemplate/blob/main/LICENSE +[projecttemplate-link]: https://github.com/ptr727/ProjectTemplate +[releases-link]: https://github.com/ptr727/ProjectTemplate/releases diff --git a/catalog/snippets/configs/vscode-tasks-python.json b/catalog/snippets/configs/vscode-tasks-python.json index 137c627b..5f2068c7 100644 --- a/catalog/snippets/configs/vscode-tasks-python.json +++ b/catalog/snippets/configs/vscode-tasks-python.json @@ -1,184 +1,184 @@ -{ - "version": "2.0.0", - "tasks": [ - // Python language group. - // Every command-executing task is `type: process`, as in the .NET snippet. - // The aggregators are dependsOn-only. - // The command runs directly rather than through a shell, which avoids `&&` and `;` chaining. - // That chaining is not portable, since Windows PowerShell 5.1 rejects `&&` and is still the default - // VS Code task shell on many setups. - // Sequencing is expressed with `dependsOrder: sequence` and `dependsOn`, never shell operators. - // - // The first tasks are the Python clean-compile set, per CODESTYLE.md "Local Development Loop": - // `ruff format`, then `ruff check`, then the type checker. - // A repo whose CI type checker is mypy, with pyright editor-only, swaps "Python Types" to - // `uv run mypy src`. - // A pyright-strict repo keeps `uv run pyright`. - // Adapt the target paths to the repo's package layout. - // A lint-only Scripts-profile subtree, with no uv project and no `uv.lock`, runs every tool via - // `uvx <tool>` rather than `uv run <tool>`. - // The type-checker swap composes with it: `uvx mypy src` for a mypy repo, `uvx pyright` otherwise. - // It omits the pytest, coverage, and `uv sync` tasks, per CODESTYLE.md Python "Two profiles". - { - "label": "Ruff Format", - "type": "process", - "command": "uv", - "args": [ - "run", - "ruff", - "format" - ], - "problemMatcher": [], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": "Ruff Check", - "type": "process", - "command": "uv", - "args": [ - "run", - "ruff", - "check" - ], - "problemMatcher": [], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": "Python Types", - "type": "process", - "command": "uv", - "args": [ - "run", - "pyright" - ], - "problemMatcher": [], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - // The clean-compile aggregator formats in place, then lints, then type-checks, in order. - // No shell chaining is involved. - // It is named "Format" rather than "Verify" because it mutates, like the sibling ".NET Format". - "label": "Python Format", - "dependsOrder": "sequence", - "dependsOn": [ - "Ruff Format", - "Ruff Check", - "Python Types" - ], - "group": { "kind": "build", "isDefault": true }, - "problemMatcher": [] - }, - { - "label": "Python Test", - "type": "process", - "command": "uv", - "args": [ - "run", - "pytest" - ], - "group": { "kind": "test", "isDefault": true }, - "problemMatcher": [], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - // Convenience / project-specific tasks (adapt or drop per repo). - { - "label": "uv Sync", - "type": "process", - "command": "uv", - "args": [ - "sync" - ], - "problemMatcher": [], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": "uv Upgrade", - "type": "process", - "command": "uv", - "args": [ - "sync", - "--upgrade" - ], - "problemMatcher": [], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - // Lint group, run on demand via Docker, where --pull=always forces each to re-pull :latest. - // It mirrors the CI lint gate. - // Broad live spell-checking is the cspell extension's job. - // Pre-commit does formatting only. - // These tasks are language-agnostic, identical to the .NET snippet's Lint group. - { - "label": "Lint: EditorConfig", - "type": "process", - "command": "docker", - "args": [ "run", "--rm", "--pull=always", "-v", "${workspaceFolder}:/check", "-w", "/check", "mstruebing/editorconfig-checker:latest" ], - "problemMatcher": [], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": "Lint: Workflows", - "type": "process", - "command": "docker", - "args": [ "run", "--rm", "--pull=always", "-v", "${workspaceFolder}:/repo", "-w", "/repo", "rhysd/actionlint:latest", "-color" ], - "problemMatcher": [], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": "Lint: Markdown", - "type": "process", - "command": "docker", - "args": [ "run", "--rm", "--pull=always", "-v", "${workspaceFolder}:/workdir", "-w", "/workdir", "davidanson/markdownlint-cli2:latest", "**/*.md" ], - "problemMatcher": [], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": "Lint: Spelling", - "type": "process", - "command": "docker", - "args": [ "run", "--rm", "--pull=always", "-v", "${workspaceFolder}:/workdir", "-w", "/workdir", "ghcr.io/streetsidesoftware/cspell:latest", "--no-progress", "README.md", "HISTORY.md" ], - "problemMatcher": [], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": "Lint: All", - "dependsOrder": "sequence", - "dependsOn": [ - "Lint: EditorConfig", - "Lint: Workflows", - "Lint: Markdown", - "Lint: Spelling" - ], - "problemMatcher": [] - } - ] -} +{ + "version": "2.0.0", + "tasks": [ + // Python language group. + // Every command-executing task is `type: process`, as in the .NET snippet. + // The aggregators are dependsOn-only. + // The command runs directly rather than through a shell, which avoids `&&` and `;` chaining. + // That chaining is not portable, since Windows PowerShell 5.1 rejects `&&` and is still the default + // VS Code task shell on many setups. + // Sequencing is expressed with `dependsOrder: sequence` and `dependsOn`, never shell operators. + // + // The first tasks are the Python clean-compile set, per CODESTYLE.md "Local Development Loop": + // `ruff format`, then `ruff check`, then the type checker. + // A repo whose CI type checker is mypy, with pyright editor-only, swaps "Python Types" to + // `uv run mypy src`. + // A pyright-strict repo keeps `uv run pyright`. + // Adapt the target paths to the repo's package layout. + // A lint-only Scripts-profile subtree, with no uv project and no `uv.lock`, runs every tool via + // `uvx <tool>` rather than `uv run <tool>`. + // The type-checker swap composes with it: `uvx mypy src` for a mypy repo, `uvx pyright` otherwise. + // It omits the pytest, coverage, and `uv sync` tasks, per CODESTYLE.md Python "Two profiles". + { + "label": "Ruff Format", + "type": "process", + "command": "uv", + "args": [ + "run", + "ruff", + "format" + ], + "problemMatcher": [], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": "Ruff Check", + "type": "process", + "command": "uv", + "args": [ + "run", + "ruff", + "check" + ], + "problemMatcher": [], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": "Python Types", + "type": "process", + "command": "uv", + "args": [ + "run", + "pyright" + ], + "problemMatcher": [], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + // The clean-compile aggregator formats in place, then lints, then type-checks, in order. + // No shell chaining is involved. + // It is named "Format" rather than "Verify" because it mutates, like the sibling ".NET Format". + "label": "Python Format", + "dependsOrder": "sequence", + "dependsOn": [ + "Ruff Format", + "Ruff Check", + "Python Types" + ], + "group": { "kind": "build", "isDefault": true }, + "problemMatcher": [] + }, + { + "label": "Python Test", + "type": "process", + "command": "uv", + "args": [ + "run", + "pytest" + ], + "group": { "kind": "test", "isDefault": true }, + "problemMatcher": [], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + // Convenience / project-specific tasks (adapt or drop per repo). + { + "label": "uv Sync", + "type": "process", + "command": "uv", + "args": [ + "sync" + ], + "problemMatcher": [], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": "uv Upgrade", + "type": "process", + "command": "uv", + "args": [ + "sync", + "--upgrade" + ], + "problemMatcher": [], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + // Lint group, run on demand via Docker, where --pull=always forces each to re-pull :latest. + // It mirrors the CI lint gate. + // Broad live spell-checking is the cspell extension's job. + // Pre-commit does formatting only. + // These tasks are language-agnostic, identical to the .NET snippet's Lint group. + { + "label": "Lint: EditorConfig", + "type": "process", + "command": "docker", + "args": [ "run", "--rm", "--pull=always", "-v", "${workspaceFolder}:/check", "-w", "/check", "mstruebing/editorconfig-checker:latest" ], + "problemMatcher": [], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": "Lint: Workflows", + "type": "process", + "command": "docker", + "args": [ "run", "--rm", "--pull=always", "-v", "${workspaceFolder}:/repo", "-w", "/repo", "rhysd/actionlint:latest", "-color" ], + "problemMatcher": [], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": "Lint: Markdown", + "type": "process", + "command": "docker", + "args": [ "run", "--rm", "--pull=always", "-v", "${workspaceFolder}:/workdir", "-w", "/workdir", "davidanson/markdownlint-cli2:latest", "**/*.md" ], + "problemMatcher": [], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": "Lint: Spelling", + "type": "process", + "command": "docker", + "args": [ "run", "--rm", "--pull=always", "-v", "${workspaceFolder}:/workdir", "-w", "/workdir", "ghcr.io/streetsidesoftware/cspell:latest", "--no-progress", "README.md", "HISTORY.md" ], + "problemMatcher": [], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": "Lint: All", + "dependsOrder": "sequence", + "dependsOn": [ + "Lint: EditorConfig", + "Lint: Workflows", + "Lint: Markdown", + "Lint: Spelling" + ], + "problemMatcher": [] + } + ] +} diff --git a/catalog/snippets/configs/vscode-tasks.json b/catalog/snippets/configs/vscode-tasks.json index ee1998c8..f8d366fb 100644 --- a/catalog/snippets/configs/vscode-tasks.json +++ b/catalog/snippets/configs/vscode-tasks.json @@ -1,184 +1,184 @@ -{ - "version": "2.0.0", - "tasks": [ - // .NET language group. - // A non-.NET repo drops this group and adds its own language's tasks. - // The first three tasks are the .NET clean-compile set (CODESTYLE.md), carried verbatim. - // The rest are convenience or project-specific. - { - "label": ".NET Build", - "type": "process", - "command": "dotnet", - "args": [ - "build", - "${workspaceFolder}", - "--verbosity=diagnostic" - ], - "group": "build", - "problemMatcher": [ - "$msCompile" - ], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": ".NET Format", - "type": "process", - "command": "dotnet", - "args": [ - "format", - "style", - "--verify-no-changes", - "--severity=info", - "--verbosity=detailed" - ], - "problemMatcher": [ - "$msCompile" - ], - "presentation": { - "showReuseMessage": false, - "clear": false - }, - "dependsOrder": "sequence", - "dependsOn": [ - "CSharpier Format", - ".NET Build" - ] - }, - { - "label": "CSharpier Format", - "type": "process", - "command": "dotnet", - "args": [ - "csharpier", - "format", - "--log-level=debug", - "." - ], - "problemMatcher": [ - "$msCompile" - ], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - // Convenience / project-specific tasks (adapt or drop per repo). - { - "label": ".NET Tool Update", - "type": "process", - "command": "dotnet", - "args": [ - "tool", - "update", - "--all" - ], - "problemMatcher": [ - "$msCompile" - ], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": ".NET Benchmark", - "type": "process", - "command": "dotnet", - "args": [ - "run", - "--project", - "${workspaceFolder}/Benchmarks/Benchmarks.csproj", - "-c", - "Release", - "--", - "--artifacts", - "${workspaceFolder}/.artifacts/benchmarks" - ], - "problemMatcher": [ - "$msCompile" - ], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": ".NET Outdated Upgrade", - "type": "process", - "command": "dotnet", - "args": [ - "outdated", - "--upgrade:prompt" - ], - "problemMatcher": [ - "$msCompile" - ], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - // Lint group, run on demand via Docker, where --pull=always pins each to the current :latest. - // It mirrors the CI lint gate. - // Broad live spell-checking is the cspell extension's job. - // Pre-commit does formatting only. - { - "label": "Lint: EditorConfig", - "type": "process", - "command": "docker", - "args": [ "run", "--rm", "--pull=always", "-v", "${workspaceFolder}:/check", "-w", "/check", "mstruebing/editorconfig-checker:latest" ], - "problemMatcher": [], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": "Lint: Workflows", - "type": "process", - "command": "docker", - "args": [ "run", "--rm", "--pull=always", "-v", "${workspaceFolder}:/repo", "-w", "/repo", "rhysd/actionlint:latest", "-color" ], - "problemMatcher": [], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": "Lint: Markdown", - "type": "process", - "command": "docker", - "args": [ "run", "--rm", "--pull=always", "-v", "${workspaceFolder}:/workdir", "-w", "/workdir", "davidanson/markdownlint-cli2:latest", "**/*.md" ], - "problemMatcher": [], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": "Lint: Spelling", - "type": "process", - "command": "docker", - "args": [ "run", "--rm", "--pull=always", "-v", "${workspaceFolder}:/workdir", "-w", "/workdir", "ghcr.io/streetsidesoftware/cspell:latest", "--no-progress", "README.md", "HISTORY.md" ], - "problemMatcher": [], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": "Lint: All", - "dependsOrder": "sequence", - "dependsOn": [ - "Lint: EditorConfig", - "Lint: Workflows", - "Lint: Markdown", - "Lint: Spelling" - ], - "problemMatcher": [] - } - ] -} +{ + "version": "2.0.0", + "tasks": [ + // .NET language group. + // A non-.NET repo drops this group and adds its own language's tasks. + // The first three tasks are the .NET clean-compile set (CODESTYLE.md), carried verbatim. + // The rest are convenience or project-specific. + { + "label": ".NET Build", + "type": "process", + "command": "dotnet", + "args": [ + "build", + "${workspaceFolder}", + "--verbosity=diagnostic" + ], + "group": "build", + "problemMatcher": [ + "$msCompile" + ], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": ".NET Format", + "type": "process", + "command": "dotnet", + "args": [ + "format", + "style", + "--verify-no-changes", + "--severity=info", + "--verbosity=detailed" + ], + "problemMatcher": [ + "$msCompile" + ], + "presentation": { + "showReuseMessage": false, + "clear": false + }, + "dependsOrder": "sequence", + "dependsOn": [ + "CSharpier Format", + ".NET Build" + ] + }, + { + "label": "CSharpier Format", + "type": "process", + "command": "dotnet", + "args": [ + "csharpier", + "format", + "--log-level=debug", + "." + ], + "problemMatcher": [ + "$msCompile" + ], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + // Convenience / project-specific tasks (adapt or drop per repo). + { + "label": ".NET Tool Update", + "type": "process", + "command": "dotnet", + "args": [ + "tool", + "update", + "--all" + ], + "problemMatcher": [ + "$msCompile" + ], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": ".NET Benchmark", + "type": "process", + "command": "dotnet", + "args": [ + "run", + "--project", + "${workspaceFolder}/Benchmarks/Benchmarks.csproj", + "-c", + "Release", + "--", + "--artifacts", + "${workspaceFolder}/.artifacts/benchmarks" + ], + "problemMatcher": [ + "$msCompile" + ], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": ".NET Outdated Upgrade", + "type": "process", + "command": "dotnet", + "args": [ + "outdated", + "--upgrade:prompt" + ], + "problemMatcher": [ + "$msCompile" + ], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + // Lint group, run on demand via Docker, where --pull=always pins each to the current :latest. + // It mirrors the CI lint gate. + // Broad live spell-checking is the cspell extension's job. + // Pre-commit does formatting only. + { + "label": "Lint: EditorConfig", + "type": "process", + "command": "docker", + "args": [ "run", "--rm", "--pull=always", "-v", "${workspaceFolder}:/check", "-w", "/check", "mstruebing/editorconfig-checker:latest" ], + "problemMatcher": [], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": "Lint: Workflows", + "type": "process", + "command": "docker", + "args": [ "run", "--rm", "--pull=always", "-v", "${workspaceFolder}:/repo", "-w", "/repo", "rhysd/actionlint:latest", "-color" ], + "problemMatcher": [], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": "Lint: Markdown", + "type": "process", + "command": "docker", + "args": [ "run", "--rm", "--pull=always", "-v", "${workspaceFolder}:/workdir", "-w", "/workdir", "davidanson/markdownlint-cli2:latest", "**/*.md" ], + "problemMatcher": [], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": "Lint: Spelling", + "type": "process", + "command": "docker", + "args": [ "run", "--rm", "--pull=always", "-v", "${workspaceFolder}:/workdir", "-w", "/workdir", "ghcr.io/streetsidesoftware/cspell:latest", "--no-progress", "README.md", "HISTORY.md" ], + "problemMatcher": [], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": "Lint: All", + "dependsOrder": "sequence", + "dependsOn": [ + "Lint: EditorConfig", + "Lint: Workflows", + "Lint: Markdown", + "Lint: Spelling" + ], + "problemMatcher": [] + } + ] +} diff --git a/catalog/snippets/devcontainer/dotnet/devcontainer.json b/catalog/snippets/devcontainer/dotnet/devcontainer.json index 7210018d..781ce9b7 100644 --- a/catalog/snippets/devcontainer/dotnet/devcontainer.json +++ b/catalog/snippets/devcontainer/dotnet/devcontainer.json @@ -1,61 +1,61 @@ -{ - "name": "ProjectTemplate (.NET)", - "image": "mcr.microsoft.com/devcontainers/dotnet:1-10.0", - - "features": { - "ghcr.io/devcontainers/features/common-utils:2": {}, - "ghcr.io/devcontainers/features/github-cli:1": {} - }, - - "mounts": [ - { - "source": "${localEnv:HOME}${localEnv:USERPROFILE}/.ssh/id_ed25519.pub", - "target": "/home/vscode/.ssh/id_ed25519.pub", - "type": "bind", - "readonly": true - }, - { - "source": "${localEnv:HOME}${localEnv:USERPROFILE}/.config/git/allowed_signers", - "target": "/home/vscode/.config/git/allowed_signers", - "type": "bind", - "readonly": true - }, - { - "source": "${localEnv:HOME}${localEnv:USERPROFILE}/.config/gh", - "target": "/home/vscode/.config/gh", - "type": "bind", - "readonly": false - } - ], - - "remoteUser": "vscode", - - // The bind-mount on macOS hosts surfaces /home/vscode/.ssh as root-owned. - // Chown it back so writes from inside the container land cleanly. - // Those include the known_hosts updates gh and git make. - // Idempotent on Linux and WSL2. - "onCreateCommand": "sudo install -d -m 700 -o vscode -g vscode /home/vscode/.ssh", - - // Restore the .NET local tools, csharpier and dotnet-outdated. - // No git hooks are installed by default, per README "Optional: enable git hooks locally". - "postCreateCommand": ".devcontainer/dotnet/post-create.sh", - - "customizations": { - "vscode": { - // Mirror of `recommendations` in ProjectTemplate.code-workspace. - "extensions": [ - "arahata.linter-actionlint", - "csharpier.csharpier-vscode", - "davidanson.vscode-markdownlint", - "editorconfig.editorconfig", - "github.vscode-github-actions", - "gruntfuggly.todo-tree", - "ms-azuretools.vscode-docker", - "ms-dotnettools.csdevkit", - "streetsidesoftware.code-spell-checker", - "timonwong.shellcheck", - "yzhang.markdown-all-in-one" - ] - } - } -} +{ + "name": "ProjectTemplate (.NET)", + "image": "mcr.microsoft.com/devcontainers/dotnet:1-10.0", + + "features": { + "ghcr.io/devcontainers/features/common-utils:2": {}, + "ghcr.io/devcontainers/features/github-cli:1": {} + }, + + "mounts": [ + { + "source": "${localEnv:HOME}${localEnv:USERPROFILE}/.ssh/id_ed25519.pub", + "target": "/home/vscode/.ssh/id_ed25519.pub", + "type": "bind", + "readonly": true + }, + { + "source": "${localEnv:HOME}${localEnv:USERPROFILE}/.config/git/allowed_signers", + "target": "/home/vscode/.config/git/allowed_signers", + "type": "bind", + "readonly": true + }, + { + "source": "${localEnv:HOME}${localEnv:USERPROFILE}/.config/gh", + "target": "/home/vscode/.config/gh", + "type": "bind", + "readonly": false + } + ], + + "remoteUser": "vscode", + + // The bind-mount on macOS hosts surfaces /home/vscode/.ssh as root-owned. + // Chown it back so writes from inside the container land cleanly. + // Those include the known_hosts updates gh and git make. + // Idempotent on Linux and WSL2. + "onCreateCommand": "sudo install -d -m 700 -o vscode -g vscode /home/vscode/.ssh", + + // Restore the .NET local tools, csharpier and dotnet-outdated. + // No git hooks are installed by default, per README "Optional: enable git hooks locally". + "postCreateCommand": ".devcontainer/dotnet/post-create.sh", + + "customizations": { + "vscode": { + // Mirror of `recommendations` in ProjectTemplate.code-workspace. + "extensions": [ + "arahata.linter-actionlint", + "csharpier.csharpier-vscode", + "davidanson.vscode-markdownlint", + "editorconfig.editorconfig", + "github.vscode-github-actions", + "gruntfuggly.todo-tree", + "ms-azuretools.vscode-docker", + "ms-dotnettools.csdevkit", + "streetsidesoftware.code-spell-checker", + "timonwong.shellcheck", + "yzhang.markdown-all-in-one" + ] + } + } +} diff --git a/catalog/snippets/devcontainer/python/devcontainer.json b/catalog/snippets/devcontainer/python/devcontainer.json index 05a466f8..878d13fd 100644 --- a/catalog/snippets/devcontainer/python/devcontainer.json +++ b/catalog/snippets/devcontainer/python/devcontainer.json @@ -1,61 +1,61 @@ -{ - "name": "ProjectTemplate (Python)", - "image": "mcr.microsoft.com/devcontainers/python:1-3.14-bookworm", - - "features": { - "ghcr.io/devcontainers/features/common-utils:2": {}, - "ghcr.io/devcontainers/features/github-cli:1": {} - }, - - "mounts": [ - { - "source": "${localEnv:HOME}${localEnv:USERPROFILE}/.ssh/id_ed25519.pub", - "target": "/home/vscode/.ssh/id_ed25519.pub", - "type": "bind", - "readonly": true - }, - { - "source": "${localEnv:HOME}${localEnv:USERPROFILE}/.config/git/allowed_signers", - "target": "/home/vscode/.config/git/allowed_signers", - "type": "bind", - "readonly": true - }, - { - "source": "${localEnv:HOME}${localEnv:USERPROFILE}/.config/gh", - "target": "/home/vscode/.config/gh", - "type": "bind", - "readonly": false - } - ], - - "remoteUser": "vscode", - - // The bind-mount on macOS hosts surfaces /home/vscode/.ssh as root-owned. - // Chown it back so writes from inside the container land cleanly. - // Those include the known_hosts updates gh and git make. - // Idempotent on Linux and WSL2. - "onCreateCommand": "sudo install -d -m 700 -o vscode -g vscode /home/vscode/.ssh", - - // Install pinned uv and pre-warm the PyPiLibrary venv. - // No git hooks are installed by default, per README "Optional: enable git hooks locally". - "postCreateCommand": ".devcontainer/python/post-create.sh", - - "customizations": { - "vscode": { - // Mirror of `recommendations` in ProjectTemplate.code-workspace. - "extensions": [ - "arahata.linter-actionlint", - "charliermarsh.ruff", - "davidanson.vscode-markdownlint", - "editorconfig.editorconfig", - "github.vscode-github-actions", - "gruntfuggly.todo-tree", - "ms-azuretools.vscode-docker", - "ms-python.python", - "streetsidesoftware.code-spell-checker", - "timonwong.shellcheck", - "yzhang.markdown-all-in-one" - ] - } - } -} +{ + "name": "ProjectTemplate (Python)", + "image": "mcr.microsoft.com/devcontainers/python:1-3.14-bookworm", + + "features": { + "ghcr.io/devcontainers/features/common-utils:2": {}, + "ghcr.io/devcontainers/features/github-cli:1": {} + }, + + "mounts": [ + { + "source": "${localEnv:HOME}${localEnv:USERPROFILE}/.ssh/id_ed25519.pub", + "target": "/home/vscode/.ssh/id_ed25519.pub", + "type": "bind", + "readonly": true + }, + { + "source": "${localEnv:HOME}${localEnv:USERPROFILE}/.config/git/allowed_signers", + "target": "/home/vscode/.config/git/allowed_signers", + "type": "bind", + "readonly": true + }, + { + "source": "${localEnv:HOME}${localEnv:USERPROFILE}/.config/gh", + "target": "/home/vscode/.config/gh", + "type": "bind", + "readonly": false + } + ], + + "remoteUser": "vscode", + + // The bind-mount on macOS hosts surfaces /home/vscode/.ssh as root-owned. + // Chown it back so writes from inside the container land cleanly. + // Those include the known_hosts updates gh and git make. + // Idempotent on Linux and WSL2. + "onCreateCommand": "sudo install -d -m 700 -o vscode -g vscode /home/vscode/.ssh", + + // Install pinned uv and pre-warm the PyPiLibrary venv. + // No git hooks are installed by default, per README "Optional: enable git hooks locally". + "postCreateCommand": ".devcontainer/python/post-create.sh", + + "customizations": { + "vscode": { + // Mirror of `recommendations` in ProjectTemplate.code-workspace. + "extensions": [ + "arahata.linter-actionlint", + "charliermarsh.ruff", + "davidanson.vscode-markdownlint", + "editorconfig.editorconfig", + "github.vscode-github-actions", + "gruntfuggly.todo-tree", + "ms-azuretools.vscode-docker", + "ms-python.python", + "streetsidesoftware.code-spell-checker", + "timonwong.shellcheck", + "yzhang.markdown-all-in-one" + ] + } + } +} diff --git a/catalog/snippets/husky/README.md b/catalog/snippets/husky/README.md index bb417de9..0494e9d6 100644 --- a/catalog/snippets/husky/README.md +++ b/catalog/snippets/husky/README.md @@ -1,7 +1,7 @@ -# Husky snippet - -`pre-commit` is the reference git pre-commit hook (installed under `.husky/` by Husky). It runs **language formatting and style only**: CSharpier and `dotnet format` style via `dotnet husky run` for .NET, or ruff for a Python repo. Native tooling and no Docker is what keeps it fast. - -Full linting (line endings, workflow YAML, Markdown, spelling) is **not** run in the hook. It runs in CI as pinned action wrappers, and on demand via the VS Code **Lint** tasks in `catalog/snippets/configs/vscode-tasks.json` (Docker at `:latest`). Keeping the doc linters out of the hook is what keeps it simple. - -A copied `.husky/pre-commit` is an extensionless shebang script, so pin it to **LF** in both `.gitattributes` (`.husky/pre-commit text eol=lf`) and `.editorconfig` (`[.husky/pre-commit] end_of_line = lf`). A CRLF shebang breaks execution. Drop the `dotnet husky run` line in a non-.NET repo. +# Husky snippet + +`pre-commit` is the reference git pre-commit hook (installed under `.husky/` by Husky). It runs **language formatting and style only**: CSharpier and `dotnet format` style via `dotnet husky run` for .NET, or ruff for a Python repo. Native tooling and no Docker is what keeps it fast. + +Full linting (line endings, workflow YAML, Markdown, spelling) is **not** run in the hook. It runs in CI as pinned action wrappers, and on demand via the VS Code **Lint** tasks in `catalog/snippets/configs/vscode-tasks.json` (Docker at `:latest`). Keeping the doc linters out of the hook is what keeps it simple. + +A copied `.husky/pre-commit` is an extensionless shebang script, so pin it to **LF** in `.gitattributes` (`.husky/pre-commit text eol=lf`), git-level enforcement independent of the editor. The fleet's `[*]` `.editorconfig` default already gives it LF, no path-specific override needed. A CRLF shebang breaks execution. Drop the `dotnet husky run` line in a non-.NET repo. diff --git a/catalog/snippets/vscode/README.md b/catalog/snippets/vscode/README.md index 94b04a3c..06fe1ac7 100644 --- a/catalog/snippets/vscode/README.md +++ b/catalog/snippets/vscode/README.md @@ -1,32 +1,32 @@ -# VS Code Workspace Catalog - -The shared `.code-workspace` set for the fleet: the standard extensions every repo recommends, the language-specific additions, and the settings that go with them. Each piece is a copyable JSON fragment in this directory. `base.jsonc` carries the standard set, and `dotnet.jsonc`, `python.jsonc`, and `docker.jsonc` carry the per-type additions. A repo's `<Repo>.code-workspace` composes `base.jsonc` plus the fragments for the languages and targets it ships. Discovered from the fleet's workspace files. - -## Standard Extensions (every repo) - -- **`davidanson.vscode-markdownlint`** - Markdown lint, sharing `.markdownlint-cli2.jsonc`. -- **`streetsidesoftware.code-spell-checker`** - cSpell, sharing `cspell.json`. -- **`editorconfig.editorconfig`** - applies `.editorconfig`. -- **`yzhang.markdown-all-in-one`** - Markdown editing and the auto-generated Table of Contents. -- **`fanaticpythoner.better-todo-tree`** - surfaces TODO/FIXME markers. -- **`github.vscode-github-actions`** - GitHub Actions authoring. -- **`arahata.linter-actionlint`** - actionlint for workflow YAML. -- **`timonwong.shellcheck`** - shellcheck for shell scripts. -- **`anthropic.claude-code`** - the coding agent. - -## Language and Target Additions - -- **.NET / C#** (`dotnet.jsonc`): `ms-dotnettools.csdevkit`, `csharpier.csharpier-vscode`; format-on-save for `[csharp]` via CSharpier. -- **Python** (`python.jsonc`): `ms-python.python`, `ms-python.vscode-pylance`, `charliermarsh.ruff`, `ms-python.mypy-type-checker`; format-on-save and import organization for `[python]` via Ruff. -- **Docker** (`docker.jsonc`): `ms-azuretools.vscode-docker`. - -## Settings - -- **Table of Contents**: `"markdown.extension.toc.levels": "2..3"`. The Markdown All in One extension includes H2 and H3 headings and updates the TOC on save. -- **Format on save** per language: C# via `csharpier.csharpier-vscode`, Python via `charliermarsh.ruff` with import organization. -- **cSpell and markdownlint** read the repo's `cspell.json` and `.markdownlint-cli2.jsonc` (linter parity). -- Trim trailing whitespace except in Markdown and plaintext, and sign off commits (`git.alwaysSignOff`). - -## Composing a Workspace - -A repo's `<Repo>.code-workspace` is a single-folder workspace (`"folders": [{ "path": "." }]`) that merges `base.jsonc` with the per-type fragments for the languages and targets it ships: the merged `extensions.recommendations` is the standard set plus each type's additions, and the merged `settings` are the shared editor defaults plus each type's formatter block. This repo's own `ProjectTemplate.code-workspace` carries `base.jsonc` only, since it ships no application language. +# VS Code Workspace Catalog + +The shared `.code-workspace` set for the fleet: the standard extensions every repo recommends, the language-specific additions, and the settings that go with them. Each piece is a copyable JSON fragment in this directory. `base.jsonc` carries the standard set, and `dotnet.jsonc`, `python.jsonc`, and `docker.jsonc` carry the per-type additions. A repo's `<Repo>.code-workspace` composes `base.jsonc` plus the fragments for the languages and targets it ships. Discovered from the fleet's workspace files. + +## Standard Extensions (every repo) + +- **`davidanson.vscode-markdownlint`** - Markdown lint, sharing `.markdownlint-cli2.jsonc`. +- **`streetsidesoftware.code-spell-checker`** - cSpell, sharing `cspell.json`. +- **`editorconfig.editorconfig`** - applies `.editorconfig`. +- **`yzhang.markdown-all-in-one`** - Markdown editing and the auto-generated Table of Contents. +- **`fanaticpythoner.better-todo-tree`** - surfaces TODO/FIXME markers. +- **`github.vscode-github-actions`** - GitHub Actions authoring. +- **`arahata.linter-actionlint`** - actionlint for workflow YAML. +- **`timonwong.shellcheck`** - shellcheck for shell scripts. +- **`anthropic.claude-code`** - the coding agent. + +## Language and Target Additions + +- **.NET / C#** (`dotnet.jsonc`): `ms-dotnettools.csdevkit`, `csharpier.csharpier-vscode`; format-on-save for `[csharp]` via CSharpier. +- **Python** (`python.jsonc`): `ms-python.python`, `ms-python.vscode-pylance`, `charliermarsh.ruff`, `ms-python.mypy-type-checker`; format-on-save and import organization for `[python]` via Ruff. +- **Docker** (`docker.jsonc`): `ms-azuretools.vscode-docker`. + +## Settings + +- **Table of Contents**: `"markdown.extension.toc.levels": "2..3"`. The Markdown All in One extension includes H2 and H3 headings and updates the TOC on save. +- **Format on save** per language: C# via `csharpier.csharpier-vscode`, Python via `charliermarsh.ruff` with import organization. +- **cSpell and markdownlint** read the repo's `cspell.json` and `.markdownlint-cli2.jsonc` (linter parity). +- Trim trailing whitespace except in Markdown and plaintext, and sign off commits (`git.alwaysSignOff`). + +## Composing a Workspace + +A repo's `<Repo>.code-workspace` is a single-folder workspace (`"folders": [{ "path": "." }]`) that merges `base.jsonc` with the per-type fragments for the languages and targets it ships: the merged `extensions.recommendations` is the standard set plus each type's additions, and the merged `settings` are the shared editor defaults plus each type's formatter block. This repo's own `ProjectTemplate.code-workspace` carries `base.jsonc` only, since it ships no application language. diff --git a/catalog/snippets/vscode/base.jsonc b/catalog/snippets/vscode/base.jsonc index be058dc8..3a95e2e0 100644 --- a/catalog/snippets/vscode/base.jsonc +++ b/catalog/snippets/vscode/base.jsonc @@ -1,28 +1,28 @@ -// Standard workspace fragment carried by every fleet repo. -// It holds the shared editor settings and the extension set common to all repos. -// A repo's `<Repo>.code-workspace` is `{ "folders": [{ "path": "." }] }` plus this base. -// That is merged with the per-type fragments for the languages and targets it ships. -// The fragments are dotnet, python, and docker. -{ - "settings": { - "markdown.extension.toc.levels": "2..3", - "files.trimTrailingWhitespace": true, - "[markdown]": { "files.trimTrailingWhitespace": false }, - "[plaintext]": { "files.trimTrailingWhitespace": false }, - "files.encoding": "utf8", - "git.alwaysSignOff": true - }, - "extensions": { - "recommendations": [ - "anthropic.claude-code", - "arahata.linter-actionlint", - "davidanson.vscode-markdownlint", - "editorconfig.editorconfig", - "fanaticpythoner.better-todo-tree", - "github.vscode-github-actions", - "streetsidesoftware.code-spell-checker", - "timonwong.shellcheck", - "yzhang.markdown-all-in-one" - ] - } -} +// Standard workspace fragment carried by every fleet repo. +// It holds the shared editor settings and the extension set common to all repos. +// A repo's `<Repo>.code-workspace` is `{ "folders": [{ "path": "." }] }` plus this base. +// That is merged with the per-type fragments for the languages and targets it ships. +// The fragments are dotnet, python, and docker. +{ + "settings": { + "markdown.extension.toc.levels": "2..3", + "files.trimTrailingWhitespace": true, + "[markdown]": { "files.trimTrailingWhitespace": false }, + "[plaintext]": { "files.trimTrailingWhitespace": false }, + "files.encoding": "utf8", + "git.alwaysSignOff": true + }, + "extensions": { + "recommendations": [ + "anthropic.claude-code", + "arahata.linter-actionlint", + "davidanson.vscode-markdownlint", + "editorconfig.editorconfig", + "fanaticpythoner.better-todo-tree", + "github.vscode-github-actions", + "streetsidesoftware.code-spell-checker", + "timonwong.shellcheck", + "yzhang.markdown-all-in-one" + ] + } +} diff --git a/catalog/snippets/vscode/docker.jsonc b/catalog/snippets/vscode/docker.jsonc index 1ccce349..10eb3170 100644 --- a/catalog/snippets/vscode/docker.jsonc +++ b/catalog/snippets/vscode/docker.jsonc @@ -1,9 +1,9 @@ -// Docker additions, merged into the base fragment for a repo that ships a Docker image. -// The Docker extension adds authoring support only, with no settings of its own. -{ - "extensions": { - "recommendations": [ - "ms-azuretools.vscode-docker" - ] - } -} +// Docker additions, merged into the base fragment for a repo that ships a Docker image. +// The Docker extension adds authoring support only, with no settings of its own. +{ + "extensions": { + "recommendations": [ + "ms-azuretools.vscode-docker" + ] + } +} diff --git a/catalog/snippets/vscode/dotnet.jsonc b/catalog/snippets/vscode/dotnet.jsonc index a416deeb..580da509 100644 --- a/catalog/snippets/vscode/dotnet.jsonc +++ b/catalog/snippets/vscode/dotnet.jsonc @@ -1,15 +1,15 @@ -// .NET / C# additions: merge into the base fragment for a repo that ships C#. -{ - "settings": { - "[csharp]": { - "editor.defaultFormatter": "csharpier.csharpier-vscode", - "editor.formatOnSave": true - } - }, - "extensions": { - "recommendations": [ - "ms-dotnettools.csdevkit", - "csharpier.csharpier-vscode" - ] - } -} +// .NET / C# additions: merge into the base fragment for a repo that ships C#. +{ + "settings": { + "[csharp]": { + "editor.defaultFormatter": "csharpier.csharpier-vscode", + "editor.formatOnSave": true + } + }, + "extensions": { + "recommendations": [ + "ms-dotnettools.csdevkit", + "csharpier.csharpier-vscode" + ] + } +} diff --git a/catalog/snippets/vscode/python.jsonc b/catalog/snippets/vscode/python.jsonc index a35b1bf3..df1a2a3a 100644 --- a/catalog/snippets/vscode/python.jsonc +++ b/catalog/snippets/vscode/python.jsonc @@ -1,18 +1,18 @@ -// Python additions: merge into the base fragment for a repo that ships Python. -{ - "settings": { - "[python]": { - "editor.defaultFormatter": "charliermarsh.ruff", - "editor.formatOnSave": true, - "editor.codeActionsOnSave": { "source.organizeImports": "explicit" } - } - }, - "extensions": { - "recommendations": [ - "ms-python.python", - "ms-python.vscode-pylance", - "charliermarsh.ruff", - "ms-python.mypy-type-checker" - ] - } -} +// Python additions: merge into the base fragment for a repo that ships Python. +{ + "settings": { + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { "source.organizeImports": "explicit" } + } + }, + "extensions": { + "recommendations": [ + "ms-python.python", + "ms-python.vscode-pylance", + "charliermarsh.ruff", + "ms-python.mypy-type-checker" + ] + } +} diff --git a/catalog/snippets/workflows/README.md b/catalog/snippets/workflows/README.md index 06c56df3..5a6c21b2 100644 --- a/catalog/snippets/workflows/README.md +++ b/catalog/snippets/workflows/README.md @@ -1,25 +1,25 @@ -# Workflow snippets - -The reusable build/publish workflow tasks a code-shipping repo runs. They are **inert reference here**: this repo is source-only and keeps just the orchestrator set (`test-pull-request`, `publish-release`, `validate-task`, `merge-bot-pull-request`) in `.github/workflows/`, plus the hub-hosted reusable tasks a downstream repo reaches rather than carries (`merge-bot-task`, per [`docs/reusable-workflows.md`][reusable-workflows]). Each file below is the canonical implementation of one or more `WORKFLOW.md` guarantees. The audit asserts a downstream repo's own Actions satisfy those guarantees, not that they match these bytes. - -| File | Role | WORKFLOW.md guarantees | -| --- | --- | --- | -| `merge-bot-pull-request.yml` | Caller stub for the hub-hosted merge-bot task, pinned to a hub release, the shape every repo carries | D8.1, D8.3, D8.4 | -| `build-release-task.yml` | Multi-target release orchestrator: get-version, validate-release, github-release plus per-target build jobs | D3, D4, D5, D6 | -| `get-version-task.yml` | NBGV version/tag computation (reusable) | D3 | -| `publish-plan-task.yml` | Single-source release-gate decision (publish? stable?) reused by every publish-release job | D4 | -| `build-executable-task.yml` | Console/executable per-runtime publish, aggregate to one release asset | D5, D6, and section 6 Console walkthrough | -| `build-nugetlibrary-task.yml` | Build + `dotnet nuget push` (OIDC), upload release asset | D3.4, D4.4, D6, and section 6 NuGet walkthrough | -| `build-pypilibrary-task.yml` | Build PyPI package, with publishing split to an OIDC job | D3.4, D4, D7.2, and section 6 PyPI walkthrough | -| `build-docker-task.yml` | Multi-arch image build + push, registry layer cache | D4.4, D6, D9.4, and section 6 Docker walkthrough | -| `build-datebadge-task.yml` | BYOB date/last-build badge on the default branch | D4, and section 3 Release Model | -| `publish-docker-readme-task.yml` | Push the size-limited Docker Hub overview | D2.4, and section 6 Docker walkthrough | -| `deploy-site.yml` | Dispatch entry point for a site deploy: environment choice, per-environment concurrency, ref gate, shared validation | D2.1, D2.3, D7.1 | -| `deploy-site-task.yml` | Build a site and ship it to a filesystem on a host the project owns, then verify against the running host | D4.6, D5.6, D7.2 (section 6 static-site walkthrough) | -| `check-upstream-version-task.yml` | Upstream-version tracker for wrapper repos | D3.5, D8.3 | -| `run-codegen-pull-request-task.yml` | Deterministic codegen executor (per-branch PR) | D8.2 | -| `run-periodic-codegen-pull-request.yml` | Scheduled codegen trigger over both branches | D8.2 | - -<!-- Repo --> - -[reusable-workflows]: ../../../docs/reusable-workflows.md +# Workflow snippets + +The reusable build/publish workflow tasks a code-shipping repo runs. They are **inert reference here**: this repo is source-only and keeps just the orchestrator set (`test-pull-request`, `publish-release`, `validate-task`, `merge-bot-pull-request`) in `.github/workflows/`, plus the hub-hosted reusable tasks a downstream repo reaches rather than carries (`merge-bot-task`, per [`docs/reusable-workflows.md`][reusable-workflows]). Each file below is the canonical implementation of one or more `WORKFLOW.md` guarantees. The audit asserts a downstream repo's own Actions satisfy those guarantees, not that they match these bytes. + +| File | Role | WORKFLOW.md guarantees | +| --- | --- | --- | +| `merge-bot-pull-request.yml` | Caller stub for the hub-hosted merge-bot task, pinned to a hub release, the shape every repo carries | D8.1, D8.3, D8.4 | +| `build-release-task.yml` | Multi-target release orchestrator: get-version, validate-release, github-release plus per-target build jobs | D3, D4, D5, D6 | +| `get-version-task.yml` | NBGV version/tag computation (reusable) | D3 | +| `publish-plan-task.yml` | Single-source release-gate decision (publish? stable?) reused by every publish-release job | D4 | +| `build-executable-task.yml` | Console/executable per-runtime publish, aggregate to one release asset | D5, D6, and section 6 Console walkthrough | +| `build-nugetlibrary-task.yml` | Build + `dotnet nuget push` (OIDC), upload release asset | D3.4, D4.4, D6, and section 6 NuGet walkthrough | +| `build-pypilibrary-task.yml` | Build PyPI package, with publishing split to an OIDC job | D3.4, D4, D7.2, and section 6 PyPI walkthrough | +| `build-docker-task.yml` | Multi-arch image build + push, registry layer cache | D4.4, D6, D9.4, and section 6 Docker walkthrough | +| `build-datebadge-task.yml` | BYOB date/last-build badge on the default branch | D4, and section 3 Release Model | +| `publish-docker-readme-task.yml` | Push the size-limited Docker Hub overview | D2.4, and section 6 Docker walkthrough | +| `deploy-site.yml` | Dispatch entry point for a site deploy: environment choice, per-environment concurrency, ref gate, shared validation | D2.1, D2.3, D7.1 | +| `deploy-site-task.yml` | Build a site and ship it to a filesystem on a host the project owns, then verify against the running host | D4.6, D5.6, D7.2 (section 6 static-site walkthrough) | +| `check-upstream-version-task.yml` | Upstream-version tracker for wrapper repos | D3.5, D8.3 | +| `run-codegen-pull-request-task.yml` | Deterministic codegen executor (per-branch PR) | D8.2 | +| `run-periodic-codegen-pull-request.yml` | Scheduled codegen trigger over both branches | D8.2 | + +<!-- Repo --> + +[reusable-workflows]: ../../../docs/reusable-workflows.md diff --git a/catalog/snippets/workflows/check-upstream-version-task.yml b/catalog/snippets/workflows/check-upstream-version-task.yml index af63a5e9..5c5980a3 100644 --- a/catalog/snippets/workflows/check-upstream-version-task.yml +++ b/catalog/snippets/workflows/check-upstream-version-task.yml @@ -89,9 +89,9 @@ jobs: # A valid-JSON-but-non-object file would otherwise break the `$old + $new` union below. if [ -f "$STATE_FILE" ] && old="$(jq -S 'if type == "object" then . else empty end' "$STATE_FILE" 2>/dev/null)" && [ -n "$old" ]; then :; else old='{}'; fi - # Write the canonical state file as CRLF, since jq emits LF, to match .editorconfig's .json rule. + # Write the canonical state file as jq emits it, LF, matching .editorconfig's .json rule. # Unchanged content means no diff, and therefore no PR. - printf '%s\n' "$new" | sed 's/$/\r/' > "$STATE_FILE" + printf '%s\n' "$new" > "$STATE_FILE" # Diff across the union of old+new keys so an added, moved, or removed key is all caught. # Removals carry a null .new. diff --git a/cspell.json b/cspell.json index 80d0f573..7c639fa5 100644 --- a/cspell.json +++ b/cspell.json @@ -1,148 +1,148 @@ -{ - "version": "0.2", - "language": "en-US", - "ignorePaths": [ - ".git/**", - "reports/*/**", - "**/*.min.*" - ], - "ignoreRegExpList": [ - "/```mermaid[\\s\\S]*?```/g" - ], - "words": [ - "accessibilities", - "Allman", - "apikey", - "arahata", - "astral", - "autoremove", - "buildcache", - "buildmetadata", - "buildtransitive", - "Buildx", - "bumpable", - "charliermarsh", - "chowned", - "chowns", - "codegen", - "commitish", - "conftest", - "contentfiles", - "cref", - "crefs", - "csdevkit", - "datebadge", - "davidanson", - "debuglevel", - "desync", - "devcontainer", - "distros", - "dnsmasq", - "dockerbuild", - "Dockerfiles", - "dockerhub", - "Docstrings", - "dogfoods", - "dorny", - "dotnettools", - "downstreams", - "dpkg", - "dryrun", - "Emby", - "envsubst", - "esphome", - "extensionless", - "fanaticpythoner", - "finalizers", - "Genericize", - "gpgsign", - "gpgv", - "gruntfuggly", - "HACS", - "hass", - "hatchling", - "heredocs", - "homeassistant", - "idempotent", - "idempotently", - "isort", - "Jellyfin", - "Keychain", - "keyring", - "keyrings", - "kicad", - "koalaman", - "lastbuild", - "libsecret", - "LINQ", - "logfile", - "mktemp", - "mstruebing", - "mypy", - "nameof", - "nbgv", - "nektos", - "Nerdbank", - "noninteractive", - "noqa", - "nuget", - "nugetlibrary", - "onCreateCommand", - "opencontainers", - "Optix", - "othercommand", - "paramref", - "Pieter", - "pipefail", - "postCreateCommand", - "prerelease", - "ProjectTemplate", - "purpleair", - "pydantic", - "pypa", - "pypi", - "pypilibrary", - "pyproject", - "pyright", - "pyrightconfig", - "pytest", - "quoteoftheday", - "regen", - "resharper", - "resync", - "resynced", - "rhysd", - "Rubba", - "ruff", - "sdist", - "semver", - "Serilog", - "settingsfile", - "shellcheck", - "signingkey", - "slnx", - "snupkg", - "softprops", - "somecommand", - "sshconfig", - "stdlib", - "subsetting", - "timonwong", - "Triaging", - "tzdata", - "unbuilt", - "unconfigured", - "untriaged", - "unvalidated", - "usermod", - "USERPROFILE", - "uvx", - "venv", - "Viljoen", - "winget", - "worktree", - "Xsession", - "xunit", - "yzhang" - ] -} +{ + "version": "0.2", + "language": "en-US", + "ignorePaths": [ + ".git/**", + "reports/*/**", + "**/*.min.*" + ], + "ignoreRegExpList": [ + "/```mermaid[\\s\\S]*?```/g" + ], + "words": [ + "accessibilities", + "Allman", + "apikey", + "arahata", + "astral", + "autoremove", + "buildcache", + "buildmetadata", + "buildtransitive", + "Buildx", + "bumpable", + "charliermarsh", + "chowned", + "chowns", + "codegen", + "commitish", + "conftest", + "contentfiles", + "cref", + "crefs", + "csdevkit", + "datebadge", + "davidanson", + "debuglevel", + "desync", + "devcontainer", + "distros", + "dnsmasq", + "dockerbuild", + "Dockerfiles", + "dockerhub", + "Docstrings", + "dogfoods", + "dorny", + "dotnettools", + "downstreams", + "dpkg", + "dryrun", + "Emby", + "envsubst", + "esphome", + "extensionless", + "fanaticpythoner", + "finalizers", + "Genericize", + "gpgsign", + "gpgv", + "gruntfuggly", + "HACS", + "hass", + "hatchling", + "heredocs", + "homeassistant", + "idempotent", + "idempotently", + "isort", + "Jellyfin", + "Keychain", + "keyring", + "keyrings", + "kicad", + "koalaman", + "lastbuild", + "libsecret", + "LINQ", + "logfile", + "mktemp", + "mstruebing", + "mypy", + "nameof", + "nbgv", + "nektos", + "Nerdbank", + "noninteractive", + "noqa", + "nuget", + "nugetlibrary", + "onCreateCommand", + "opencontainers", + "Optix", + "othercommand", + "paramref", + "Pieter", + "pipefail", + "postCreateCommand", + "prerelease", + "ProjectTemplate", + "purpleair", + "pydantic", + "pypa", + "pypi", + "pypilibrary", + "pyproject", + "pyright", + "pyrightconfig", + "pytest", + "quoteoftheday", + "regen", + "resharper", + "resync", + "resynced", + "rhysd", + "Rubba", + "ruff", + "sdist", + "semver", + "Serilog", + "settingsfile", + "shellcheck", + "signingkey", + "slnx", + "snupkg", + "softprops", + "somecommand", + "sshconfig", + "stdlib", + "subsetting", + "timonwong", + "Triaging", + "tzdata", + "unbuilt", + "unconfigured", + "untriaged", + "unvalidated", + "usermod", + "USERPROFILE", + "uvx", + "venv", + "Viljoen", + "winget", + "worktree", + "Xsession", + "xunit", + "yzhang" + ] +} diff --git a/docs/content-import.md b/docs/content-import.md index 13ee2d0d..15a7f5b0 100644 --- a/docs/content-import.md +++ b/docs/content-import.md @@ -1,51 +1,51 @@ -# Content Import: Capturing a Source System (Hub-Only) - -What an agent captures, and how it verifies the capture, when a repo's initial content comes from a live external system the repo replaces (a hosted blog, a wiki, a CMS). This doc is **hub-only**, so it is not carried downstream, and it describes work the hub does when standing a repo up rather than a fact about any one repo. [`STANDUP.md`][standup] owns the create-to-conformance procedure, and this covers the one input that procedure cannot re-derive: a source system is not under version control, so what is not captured before it stops serving is gone. - -The three rules below are measured rather than predicted, from a WordPress-to-Hugo import of a 108-post site (intake #456). Read each count as the evidence for its rule, not as a constant to expect. - -## Capture While the Source Is Live - -**Capture first, and treat the capture as a deliverable rather than a step toward one.** The source is often paid for, rented, or already scheduled for shutdown, so the capture window closes on someone else's calendar. Hold the verified capture (the export, the localized external assets, a golden URL list, a manifest of content hashes) as the before-snapshot, and convert from that rather than from the live system, so every later check compares against a fixed reference instead of a moving one. - -Every failure below produces a capture that **reconciles cleanly and is wrong**. Each one is a case where the artifact the source hands you agrees with itself, which is exactly why it cannot be the thing that gates. - -## An Export Is Not a Media Capture - -A content export carries what the source's own media library holds. A page can reference an asset the library never held, and that asset appears in no export at all. - -- **Measured:** of 972 distinct media assets referenced by the content, 261 (27%) are hotlinked to a third-party host and absent from the export. -- **The inventory trap.** Half the referencing URLs are served through the CMS image proxy (`i0.wp.com/lh3.ggpht.com/...`), so a third-party asset carries a first-party hostname. An inventory keyed on the host counts those as library assets and reports full coverage of a set it never looked at. -- **Enumerate from the rendered pages, not from the export manifest.** Normalize responsive variants and generated size suffixes first, so one asset counts once rather than once per derivative. -- **Localizing externally hosted media is a required step**, not an optional pass. A third-party host is under no obligation to keep serving, and what the export omits is precisely what disappears with no notice and no error. - -## A Sitemap Is Not the URL Contract - -The sitemap is what the source advertises. The contract is what it serves, because every URL it answers is one an inbound link, a feed reader, or a search index may already hold. - -- **Measured:** the sitemap lists 111 URLs against 1,051 served. The gap is taxonomy term pages, pagination, feeds, attachment pages, and date archives. -- **The silent breaker is a default rename.** The target generator serves taxonomy roots under different names than the source (plural `/tags/` and `/categories/` against singular `/tag/` and `/category/`), so 195 term URLs return 404 while the build reports success. -- **A URL that is unlisted and unlinked still exists.** 83 date archives appear in no sitemap and are linked from nowhere on the live site, and turn up only by deriving candidates and probing. Give every class a recorded disposition (render, redirect, or drop), so a dropped URL is a decision rather than an omission. -- **Where one URL shape is ambiguous, name the discriminator.** A bare one-segment path can be either a real page or a generated attachment page, and on this site 2 of 110 were real pages. The sitemap is the discriminator there, because it lists precisely the posts and pages. -- **Consumer-visible identifiers beyond URLs get the same treatment.** A feed reader keys on the item GUID byte for byte, so a GUID carrying an `http` scheme that the import "modernizes" to `https` marks every post unread for every subscriber. Preserve what a consumer keys on, or accept the breakage deliberately and record the blast radius. -- **The gate:** a committed golden URL list generated from the **live crawl**, never from the converter output, since a list derived from the output makes the check circular. Assert a floor on the list length before checking parity, because a truncated list makes every check below it pass vacuously. A missing URL is a hard failure with one annotation each, and an extra URL is a notice, which keeps the list append-only and the floor sound. Run it in CI and again against the exact tree about to deploy. - -## A Fetch Over HTTP Is Not the Original - -A source that serves optimized derivatives returns one at the original's URL, under the original's filename. This is the strongest of the three, because it produces silent quality loss rather than visible absence. - -- **Measured:** 778 library files captured over HTTP, compared path for path against the official media export. All 778 paths present in both, 747 byte-identical, **31 different**, and 17.2 MB of image data that an HTTP capture alone would have lost. The worst case is a 1.7 MB photo returned as an 8 KB thumbnail at the same URL, a 205x reduction. -- **The false pass:** a file-count reconciliation reports 778 of 778 while 4% of the bytes are wrong. Nothing errors and nothing is missing, and the loss is visible only by opening the images. -- **Take library media from the official export, and verify by content hash against its manifest.** A count is not a verification. Import tooling whose media step is an HTTP download (a `--download-media` flag) inherits this defect, so the export is the source of record and the tool's fetch is at best a fallback for what the export omits. -- **One archive caveat:** an export archive may stream without its end-of-archive marker, so an integrity check that looks for the trailing zero blocks calls a complete archive corrupt. Verify by extracting and hashing the members, which is the check that matters anyway. - -## The Shape All Three Share - -The cheap check passes for the wrong reason. An inventory keyed on the host, a URL list read from the sitemap, and a reconciliation counted by file are each the artifact the source hands you, and each one agrees with itself. So the check that gates has to read the thing being claimed: the rendered pages for media, a live crawl for URLs, and content hashes for bytes. - -That is the floor rule in [GOVERNANCE.md "Verification Discipline"][governance-verification-discipline] applied to an import, and it carries the same property the whole section is built around. Every failure here is green. - -<!-- Repo --> - -[governance-verification-discipline]: ../GOVERNANCE.md#verification-discipline -[standup]: ../STANDUP.md +# Content Import: Capturing a Source System (Hub-Only) + +What an agent captures, and how it verifies the capture, when a repo's initial content comes from a live external system the repo replaces (a hosted blog, a wiki, a CMS). This doc is **hub-only**, so it is not carried downstream, and it describes work the hub does when standing a repo up rather than a fact about any one repo. [`STANDUP.md`][standup] owns the create-to-conformance procedure, and this covers the one input that procedure cannot re-derive: a source system is not under version control, so what is not captured before it stops serving is gone. + +The three rules below are measured rather than predicted, from a WordPress-to-Hugo import of a 108-post site (intake #456). Read each count as the evidence for its rule, not as a constant to expect. + +## Capture While the Source Is Live + +**Capture first, and treat the capture as a deliverable rather than a step toward one.** The source is often paid for, rented, or already scheduled for shutdown, so the capture window closes on someone else's calendar. Hold the verified capture (the export, the localized external assets, a golden URL list, a manifest of content hashes) as the before-snapshot, and convert from that rather than from the live system, so every later check compares against a fixed reference instead of a moving one. + +Every failure below produces a capture that **reconciles cleanly and is wrong**. Each one is a case where the artifact the source hands you agrees with itself, which is exactly why it cannot be the thing that gates. + +## An Export Is Not a Media Capture + +A content export carries what the source's own media library holds. A page can reference an asset the library never held, and that asset appears in no export at all. + +- **Measured:** of 972 distinct media assets referenced by the content, 261 (27%) are hotlinked to a third-party host and absent from the export. +- **The inventory trap.** Half the referencing URLs are served through the CMS image proxy (`i0.wp.com/lh3.ggpht.com/...`), so a third-party asset carries a first-party hostname. An inventory keyed on the host counts those as library assets and reports full coverage of a set it never looked at. +- **Enumerate from the rendered pages, not from the export manifest.** Normalize responsive variants and generated size suffixes first, so one asset counts once rather than once per derivative. +- **Localizing externally hosted media is a required step**, not an optional pass. A third-party host is under no obligation to keep serving, and what the export omits is precisely what disappears with no notice and no error. + +## A Sitemap Is Not the URL Contract + +The sitemap is what the source advertises. The contract is what it serves, because every URL it answers is one an inbound link, a feed reader, or a search index may already hold. + +- **Measured:** the sitemap lists 111 URLs against 1,051 served. The gap is taxonomy term pages, pagination, feeds, attachment pages, and date archives. +- **The silent breaker is a default rename.** The target generator serves taxonomy roots under different names than the source (plural `/tags/` and `/categories/` against singular `/tag/` and `/category/`), so 195 term URLs return 404 while the build reports success. +- **A URL that is unlisted and unlinked still exists.** 83 date archives appear in no sitemap and are linked from nowhere on the live site, and turn up only by deriving candidates and probing. Give every class a recorded disposition (render, redirect, or drop), so a dropped URL is a decision rather than an omission. +- **Where one URL shape is ambiguous, name the discriminator.** A bare one-segment path can be either a real page or a generated attachment page, and on this site 2 of 110 were real pages. The sitemap is the discriminator there, because it lists precisely the posts and pages. +- **Consumer-visible identifiers beyond URLs get the same treatment.** A feed reader keys on the item GUID byte for byte, so a GUID carrying an `http` scheme that the import "modernizes" to `https` marks every post unread for every subscriber. Preserve what a consumer keys on, or accept the breakage deliberately and record the blast radius. +- **The gate:** a committed golden URL list generated from the **live crawl**, never from the converter output, since a list derived from the output makes the check circular. Assert a floor on the list length before checking parity, because a truncated list makes every check below it pass vacuously. A missing URL is a hard failure with one annotation each, and an extra URL is a notice, which keeps the list append-only and the floor sound. Run it in CI and again against the exact tree about to deploy. + +## A Fetch Over HTTP Is Not the Original + +A source that serves optimized derivatives returns one at the original's URL, under the original's filename. This is the strongest of the three, because it produces silent quality loss rather than visible absence. + +- **Measured:** 778 library files captured over HTTP, compared path for path against the official media export. All 778 paths present in both, 747 byte-identical, **31 different**, and 17.2 MB of image data that an HTTP capture alone would have lost. The worst case is a 1.7 MB photo returned as an 8 KB thumbnail at the same URL, a 205x reduction. +- **The false pass:** a file-count reconciliation reports 778 of 778 while 4% of the bytes are wrong. Nothing errors and nothing is missing, and the loss is visible only by opening the images. +- **Take library media from the official export, and verify by content hash against its manifest.** A count is not a verification. Import tooling whose media step is an HTTP download (a `--download-media` flag) inherits this defect, so the export is the source of record and the tool's fetch is at best a fallback for what the export omits. +- **One archive caveat:** an export archive may stream without its end-of-archive marker, so an integrity check that looks for the trailing zero blocks calls a complete archive corrupt. Verify by extracting and hashing the members, which is the check that matters anyway. + +## The Shape All Three Share + +The cheap check passes for the wrong reason. An inventory keyed on the host, a URL list read from the sitemap, and a reconciliation counted by file are each the artifact the source hands you, and each one agrees with itself. So the check that gates has to read the thing being claimed: the rendered pages for media, a live crawl for URLs, and content hashes for bytes. + +That is the floor rule in [GOVERNANCE.md "Verification Discipline"][governance-verification-discipline] applied to an import, and it carries the same property the whole section is built around. Every failure here is green. + +<!-- Repo --> + +[governance-verification-discipline]: ../GOVERNANCE.md#verification-discipline +[standup]: ../STANDUP.md diff --git a/docs/devcontainer.md b/docs/devcontainer.md index 3821a628..df391de3 100644 --- a/docs/devcontainer.md +++ b/docs/devcontainer.md @@ -1,123 +1,123 @@ -# Devcontainer Setup - -This repo ships no application toolchain. It keeps two per-language [Dev Container][containers-link] definitions under `catalog/snippets/devcontainer/` as a reference for fleet code repos, each carrying one toolchain, extension surface, and `postCreateCommand`. The mechanics below (SSH commit signing, bind mounts, and `gh` auth) apply to any repo that uses them. - -| Devcontainer | Image | Toolchain | -| ------------ | ----- | --------- | -| [`catalog/snippets/devcontainer/dotnet/devcontainer.json`][devcontainer] | `mcr.microsoft.com/devcontainers/dotnet:1-10.0` | .NET 10 SDK | -| [`catalog/snippets/devcontainer/python/devcontainer.json`][devcontainer-2] | `mcr.microsoft.com/devcontainers/python:1-3.14-bookworm` | Python 3.14 + version-pinned `uv` | - -In a repo that carries one of these definitions, install the [Dev Containers extension][marketplace-link] and pick **Reopen in Container**. - -Prerequisite: complete [host setup][host-setup] first. Without git config, an SSH key, and the allowed-signers file on the host, the devcontainer cannot sign commits. - -## What's Inside (Both Containers) - -| Component | Source | Purpose | -| --------- | ------ | ------- | -| `gh` CLI | `ghcr.io/devcontainers/features/github-cli:1` | Issue/PR/release management from inside the container | -| Common utilities | `ghcr.io/devcontainers/features/common-utils:2` | bash, curl, wget, sudo, `vscode` user | -| VS Code extensions | `customizations.vscode.extensions` in each `devcontainer.json` | Mirrors the matching workspace's `recommendations` so the container has the same tooling | - -The .NET container additionally ships the `csharpier`/`dotnet-outdated` local tools (restored by `catalog/snippets/devcontainer/dotnet/post-create.sh`). The Python container additionally ships `uv` (installed by `catalog/snippets/devcontainer/python/post-create.sh` from a version-pinned URL) and pre-syncs the Python package venv where one is present. - -Each devcontainer's extension list and the matching workspace's `recommendations` are kept identical, so when you add an extension to one, add it to the other. - -## Bind Mounts (Both Containers) - -The host SSH key, allowed-signers file, and `gh` config directory are mounted into the container so commits sign correctly and `gh` is pre-authenticated **when the host stores its `gh` token in a file** (`~/.config/gh/hosts.yml`). Hosts that store the token in macOS Keychain or Linux libsecret will need an in-container `gh auth login` instead. See [`gh` credential store][gh-credential-store] below for the full picture. - -| Host path | Container path | Mode | Purpose | -| --------- | -------------- | ---- | ------- | -| `~/.ssh/id_ed25519.pub` | `/home/vscode/.ssh/id_ed25519.pub` | read-only | Public half of the SSH key. The private key never enters the container, since SSH agent forwarding handles signing. | -| `~/.config/git/allowed_signers` | `/home/vscode/.config/git/allowed_signers` | read-only | Maps your email to your public key so `git verify-commit` and `git log --show-signature` work inside the container. | -| `~/.config/gh` | `/home/vscode/.config/gh` | read-write | `gh` CLI auth state shared with the host. See [`gh` credential store][gh-credential-store] below. | - -VS Code Dev Containers automatically copies your host `~/.gitconfig` into the container at startup, so `user.name`, `user.email`, `user.signingkey`, `gpg.format`, and `commit.gpgsign` propagate without an explicit mount. - -The SSH agent is forwarded automatically by the Dev Containers extension via `SSH_AUTH_SOCK`, so signing works as long as the agent on the host has your key loaded. - -## Lifecycle Commands - -Both `devcontainer.json` files run two scripts at well-defined points: - -- **`onCreateCommand`** - `sudo install -d -m 700 -o vscode -g vscode /home/vscode/.ssh`. On macOS hosts the bind-mount surfaces `/home/vscode/.ssh` as root-owned, which would block writes from inside the container (e.g. `gh` updating `known_hosts`). This chown fixes it. Idempotent on Linux and WSL2. -- **`postCreateCommand`** - language-specific: - - .NET: `catalog/snippets/devcontainer/dotnet/post-create.sh`, which runs `dotnet tool restore` (csharpier, dotnet-outdated). - - Python: `catalog/snippets/devcontainer/python/post-create.sh`, which installs the pinned `uv` and pre-syncs the Python package if present. - -Re-runs of either are idempotent. No git hooks are installed by default. - -To force them to run again after editing a script: VS Code -> Command Palette -> **Dev Containers: Rebuild Container**. - -## `gh` Credential Store - -`gh auth login` writes its token to either a file or an OS credential store. Which one depends on your host: - -| Host | Default token storage | -| ---- | --------------------- | -| Linux | libsecret (gnome-keyring) when available, otherwise file | -| WSL2 | file (no native credential store) | -| macOS | macOS Keychain | - -The bind-mount of `~/.config/gh` covers the **file** case. If your host stores the token in Keychain or libsecret, the bind-mount carries the rest of `gh` config but **not the token**, so the container will report "no authentication" until you either: - -1. Re-run `gh auth login` inside the container (writes a file token to the mounted directory), or -2. Skip in-container `gh` and run those commands on the host instead. - -The file-token path is slightly less secure than Keychain/libsecret because it's plaintext on disk inside `~/.config/gh/hosts.yml`. For most contributors that's an acceptable trade-off, and if it isn't, use option 2. - -## Verify the Devcontainer - -After **Reopen in Container** finishes, run the language-appropriate checks. - -**Both containers.** Verify SSH signing and `gh`: - -```shell -gh auth status # logged in as you -git -c gpg.format=ssh commit -S --allow-empty -m "verify-signing" -git log --show-signature -1 # "Good 'git' signature for ..." -``` - -**.NET container** (in a repo that ships .NET): - -```shell -dotnet --version # 10.x -which uv # nothing - uv intentionally absent -dotnet build # 0 warnings, 0 errors -dotnet test # tests pass -``` - -**Python container** (in a repo that ships Python): - -```shell -uv --version # uv 0.x -which dotnet # nothing - dotnet intentionally absent -cd <package> && uv sync && uv run pytest # tests pass -``` - -If `git -c gpg.format=ssh commit -S` errors with `signing failed: no allowed signers`, the bind-mount of `allowed_signers` is missing or the file on the host is empty, so re-run the snippet in [host setup][host-setup]. - -## Troubleshooting - -**Permission denied writing to `~/.ssh/known_hosts` in the container.** The `onCreateCommand` should have chowned `~/.ssh` to `vscode`. Rebuild the container, and if it persists, open a shell and run the same `sudo install -d -m 700 -o vscode -g vscode ~/.ssh` manually. - -**`git commit` fails with "no SSH agent socket".** VS Code Dev Containers forwards `SSH_AUTH_SOCK` automatically, but only if the host has `ssh-agent` running with at least one key. Run `ssh-add -l` on the host first, and if it says "could not open a connection to your authentication agent", start the agent (see [host setup][host-setup]). - -**uv not on `PATH` after rebuild** (Python container). The post-create installer adds `~/.local/bin` to `PATH` via the user shell init scripts, which take effect on next shell. Either re-open the integrated terminal or `source ~/.bashrc`. - -**Container builds but extensions don't auto-install.** Make sure VS Code is using the Dev Containers extension (not "Remote - SSH" or "Remote - Tunnels"). The extension auto-install is keyed on `customizations.vscode.extensions` and only Dev Containers honors that. - -**Wrong-language work in the wrong container.** The `.NET` container has no `uv` and no Python extensions, and the Python container has no `dotnet` SDK and no C# extensions. This is intentional, so use the matching container rather than installing the missing toolchain ad hoc. - -<!-- Repo --> - -[devcontainer]: ../catalog/snippets/devcontainer/dotnet/devcontainer.json -[devcontainer-2]: ../catalog/snippets/devcontainer/python/devcontainer.json -[gh-credential-store]: #gh-credential-store -[host-setup]: ./host-setup.md - -<!-- External --> - -[containers-link]: https://containers.dev/ -[marketplace-link]: https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers +# Devcontainer Setup + +This repo ships no application toolchain. It keeps two per-language [Dev Container][containers-link] definitions under `catalog/snippets/devcontainer/` as a reference for fleet code repos, each carrying one toolchain, extension surface, and `postCreateCommand`. The mechanics below (SSH commit signing, bind mounts, and `gh` auth) apply to any repo that uses them. + +| Devcontainer | Image | Toolchain | +| ------------ | ----- | --------- | +| [`catalog/snippets/devcontainer/dotnet/devcontainer.json`][devcontainer] | `mcr.microsoft.com/devcontainers/dotnet:1-10.0` | .NET 10 SDK | +| [`catalog/snippets/devcontainer/python/devcontainer.json`][devcontainer-2] | `mcr.microsoft.com/devcontainers/python:1-3.14-bookworm` | Python 3.14 + version-pinned `uv` | + +In a repo that carries one of these definitions, install the [Dev Containers extension][marketplace-link] and pick **Reopen in Container**. + +Prerequisite: complete [host setup][host-setup] first. Without git config, an SSH key, and the allowed-signers file on the host, the devcontainer cannot sign commits. + +## What's Inside (Both Containers) + +| Component | Source | Purpose | +| --------- | ------ | ------- | +| `gh` CLI | `ghcr.io/devcontainers/features/github-cli:1` | Issue/PR/release management from inside the container | +| Common utilities | `ghcr.io/devcontainers/features/common-utils:2` | bash, curl, wget, sudo, `vscode` user | +| VS Code extensions | `customizations.vscode.extensions` in each `devcontainer.json` | Mirrors the matching workspace's `recommendations` so the container has the same tooling | + +The .NET container additionally ships the `csharpier`/`dotnet-outdated` local tools (restored by `catalog/snippets/devcontainer/dotnet/post-create.sh`). The Python container additionally ships `uv` (installed by `catalog/snippets/devcontainer/python/post-create.sh` from a version-pinned URL) and pre-syncs the Python package venv where one is present. + +Each devcontainer's extension list and the matching workspace's `recommendations` are kept identical, so when you add an extension to one, add it to the other. + +## Bind Mounts (Both Containers) + +The host SSH key, allowed-signers file, and `gh` config directory are mounted into the container so commits sign correctly and `gh` is pre-authenticated **when the host stores its `gh` token in a file** (`~/.config/gh/hosts.yml`). Hosts that store the token in macOS Keychain or Linux libsecret will need an in-container `gh auth login` instead. See [`gh` credential store][gh-credential-store] below for the full picture. + +| Host path | Container path | Mode | Purpose | +| --------- | -------------- | ---- | ------- | +| `~/.ssh/id_ed25519.pub` | `/home/vscode/.ssh/id_ed25519.pub` | read-only | Public half of the SSH key. The private key never enters the container, since SSH agent forwarding handles signing. | +| `~/.config/git/allowed_signers` | `/home/vscode/.config/git/allowed_signers` | read-only | Maps your email to your public key so `git verify-commit` and `git log --show-signature` work inside the container. | +| `~/.config/gh` | `/home/vscode/.config/gh` | read-write | `gh` CLI auth state shared with the host. See [`gh` credential store][gh-credential-store] below. | + +VS Code Dev Containers automatically copies your host `~/.gitconfig` into the container at startup, so `user.name`, `user.email`, `user.signingkey`, `gpg.format`, and `commit.gpgsign` propagate without an explicit mount. + +The SSH agent is forwarded automatically by the Dev Containers extension via `SSH_AUTH_SOCK`, so signing works as long as the agent on the host has your key loaded. + +## Lifecycle Commands + +Both `devcontainer.json` files run two scripts at well-defined points: + +- **`onCreateCommand`** - `sudo install -d -m 700 -o vscode -g vscode /home/vscode/.ssh`. On macOS hosts the bind-mount surfaces `/home/vscode/.ssh` as root-owned, which would block writes from inside the container (e.g. `gh` updating `known_hosts`). This chown fixes it. Idempotent on Linux and WSL2. +- **`postCreateCommand`** - language-specific: + - .NET: `catalog/snippets/devcontainer/dotnet/post-create.sh`, which runs `dotnet tool restore` (csharpier, dotnet-outdated). + - Python: `catalog/snippets/devcontainer/python/post-create.sh`, which installs the pinned `uv` and pre-syncs the Python package if present. + +Re-runs of either are idempotent. No git hooks are installed by default. + +To force them to run again after editing a script: VS Code -> Command Palette -> **Dev Containers: Rebuild Container**. + +## `gh` Credential Store + +`gh auth login` writes its token to either a file or an OS credential store. Which one depends on your host: + +| Host | Default token storage | +| ---- | --------------------- | +| Linux | libsecret (gnome-keyring) when available, otherwise file | +| WSL2 | file (no native credential store) | +| macOS | macOS Keychain | + +The bind-mount of `~/.config/gh` covers the **file** case. If your host stores the token in Keychain or libsecret, the bind-mount carries the rest of `gh` config but **not the token**, so the container will report "no authentication" until you either: + +1. Re-run `gh auth login` inside the container (writes a file token to the mounted directory), or +2. Skip in-container `gh` and run those commands on the host instead. + +The file-token path is slightly less secure than Keychain/libsecret because it's plaintext on disk inside `~/.config/gh/hosts.yml`. For most contributors that's an acceptable trade-off, and if it isn't, use option 2. + +## Verify the Devcontainer + +After **Reopen in Container** finishes, run the language-appropriate checks. + +**Both containers.** Verify SSH signing and `gh`: + +```shell +gh auth status # logged in as you +git -c gpg.format=ssh commit -S --allow-empty -m "verify-signing" +git log --show-signature -1 # "Good 'git' signature for ..." +``` + +**.NET container** (in a repo that ships .NET): + +```shell +dotnet --version # 10.x +which uv # nothing - uv intentionally absent +dotnet build # 0 warnings, 0 errors +dotnet test # tests pass +``` + +**Python container** (in a repo that ships Python): + +```shell +uv --version # uv 0.x +which dotnet # nothing - dotnet intentionally absent +cd <package> && uv sync && uv run pytest # tests pass +``` + +If `git -c gpg.format=ssh commit -S` errors with `signing failed: no allowed signers`, the bind-mount of `allowed_signers` is missing or the file on the host is empty, so re-run the snippet in [host setup][host-setup]. + +## Troubleshooting + +**Permission denied writing to `~/.ssh/known_hosts` in the container.** The `onCreateCommand` should have chowned `~/.ssh` to `vscode`. Rebuild the container, and if it persists, open a shell and run the same `sudo install -d -m 700 -o vscode -g vscode ~/.ssh` manually. + +**`git commit` fails with "no SSH agent socket".** VS Code Dev Containers forwards `SSH_AUTH_SOCK` automatically, but only if the host has `ssh-agent` running with at least one key. Run `ssh-add -l` on the host first, and if it says "could not open a connection to your authentication agent", start the agent (see [host setup][host-setup]). + +**uv not on `PATH` after rebuild** (Python container). The post-create installer adds `~/.local/bin` to `PATH` via the user shell init scripts, which take effect on next shell. Either re-open the integrated terminal or `source ~/.bashrc`. + +**Container builds but extensions don't auto-install.** Make sure VS Code is using the Dev Containers extension (not "Remote - SSH" or "Remote - Tunnels"). The extension auto-install is keyed on `customizations.vscode.extensions` and only Dev Containers honors that. + +**Wrong-language work in the wrong container.** The `.NET` container has no `uv` and no Python extensions, and the Python container has no `dotnet` SDK and no C# extensions. This is intentional, so use the matching container rather than installing the missing toolchain ad hoc. + +<!-- Repo --> + +[devcontainer]: ../catalog/snippets/devcontainer/dotnet/devcontainer.json +[devcontainer-2]: ../catalog/snippets/devcontainer/python/devcontainer.json +[gh-credential-store]: #gh-credential-store +[host-setup]: ./host-setup.md + +<!-- External --> + +[containers-link]: https://containers.dev/ +[marketplace-link]: https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers diff --git a/docs/fleet-map.md b/docs/fleet-map.md index 67ae96a6..2408b653 100644 --- a/docs/fleet-map.md +++ b/docs/fleet-map.md @@ -1,385 +1,385 @@ -# Fleet Map and Gap Register (Hub-Only) - -The **map** of how a human or an agent enters the fleet system, the **register** of the gaps and loose ends between its parts, and the roadmap that closes them. This doc is **hub-only** and is not carried downstream, because it describes the hub's own seams rather than a fact about any one repo. It is a map and never the procedure: [`AGENTS.md`][agents], [`STANDUP.md`][standup], [`RESYNC.md`][resync], and [`AUDIT.md`][audit] keep authority over their flows, and where this doc and a procedure doc disagree, the procedure doc wins and this map is what needs fixing. - -**Maintenance rule.** A pull request that closes a register gap edits that gap's row and detail block in the same change, per [GOVERNANCE.md "Durable Knowledge and Self-Improvement"][governance-durable-knowledge]. A register describing a gap that is already closed is itself stale prose, so the register is only trustworthy while this rule holds. The same rule covers the map: a pull request that changes a mapped seam, or a flow this doc draws or points to, updates this doc in the same change. - -## Table of Contents <!-- omit from toc --> - -- [Scope and Non-Goals](#scope-and-non-goals) -- [System Map](#system-map) -- [Entry Points](#entry-points) - - [Pre-Agent Cold Start](#pre-agent-cold-start) - - [A New Repository](#a-new-repository) - - [A Stale Repository](#a-stale-repository) - - [Daily Development in a Conformant Repository](#daily-development-in-a-conformant-repository) - - [Hub-Side Operations](#hub-side-operations) -- [Skills Install Model](#skills-install-model) -- [Gap Register](#gap-register) -- [Proposed Skills](#proposed-skills) - - [audit-a-repo](#audit-a-repo) - - [workflow-ci-contract](#workflow-ci-contract) - - [skill-lifecycle](#skill-lifecycle) - - [agent-conduct](#agent-conduct) -- [Peer Messaging](#peer-messaging) -- [Simplified Technical English Evaluation](#simplified-technical-english-evaluation) -- [Adoption and Operation Roadmap](#adoption-and-operation-roadmap) - - [P0: This Pull Request](#p0-this-pull-request) - - [P1: Close the Install Model](#p1-close-the-install-model) - - [P2: Close the Skill Coverage](#p2-close-the-skill-coverage) - - [P3: Audit-Depth Decisions](#p3-audit-depth-decisions) - - [P4: Steady State](#p4-steady-state) -- [Decision Ledger Cross-References](#decision-ledger-cross-references) - -## Scope and Non-Goals - -This doc governs five things: the entry-point routing map, the gap register with a defined handoff per gap, the resolved skills install model, the proposals for new skills, and the phased roadmap. It does not author skills (skills ship through the [`.agents/skills/`][skills-readme] pipeline in changes of their own), does not restate any procedure, and treats multi-agent coordination as a documented pattern only, with the rules in [`docs/peer-messaging.md`][peer-messaging]. The version history that produced the current skill-based model is in [`HISTORY.md`][history], so this doc states only what is. - -## System Map - -```mermaid -flowchart TD - agents["AGENTS.md router"] --> standup[STANDUP.md] - agents --> resync[RESYNC.md] - resync --> audit[AUDIT.md] - standup --> audit - law["GOVERNANCE.md / CODESTYLE.md / WORKFLOW.md"] --> agents - spec["spec/ + registry/"] --> audit - scripts["scripts/ hub-hosted tooling"] --> audit - skills[".agents/skills/ source"] --> build[build_dist.py] - build --> plugin[".claude-plugin/ generated"] - plugin --> install[skills_install.py] - skills --> install - install --> host["host: ~/.agents/skills + plugin + stamp"] - hostsetup["host-setup/ cold start"] --> host - ci["CI gates + prose-gate action"] --> law -``` - -The layers, one line each. The lifecycle docs route and procedure ([`AGENTS.md`][agents] routes, [`STANDUP.md`][standup] creates, [`RESYNC.md`][resync] re-lines, [`AUDIT.md`][audit] measures). The law docs hold the rules ([`GOVERNANCE.md`][governance] cross-cutting, [`CODESTYLE.md`][codestyle] per language, [`WORKFLOW.md`][workflow] the CI/CD contract). The machine ground truth is [`spec/`][files] plus [`registry/repos.json`][repos]. The hub-hosted tooling is [`scripts/`][scripts-readme], reached rather than carried. The skills source of truth is [`.agents/skills/`][skills-readme], and [`scripts/build_dist.py`][build-dist] generates the Claude Code plugin under [`.claude-plugin/`][marketplace] from it. [`scripts/skills_install.py`][skills-install] installs both forms per machine. [`host-setup/`][host-setup-doc] provisions a host from a stock OS. CI enforces the deterministic subset of the rules on every pull request. - -## Entry Points - -Five doors into the system. Each subsection names its flow, the docs that own it, and the closed register gaps that sit on its path. Where the owning procedure doc draws its own flow diagram, that diagram keeps authority and this map points to it rather than carrying a copy that can drift. The diagrams below draw only the flows no procedure doc draws. - -### Pre-Agent Cold Start - -A fresh OS install, no git, no agent. The operator copy-pastes the loader lines from [`host-setup/README.md`][host-setup-readme], and everything after that is scripted. No agent exists at this stage, so this path must work from prose and copy-paste alone. - -```mermaid -flowchart TD - os["stock OS install"] --> paste["copy-paste loader lines"] - paste --> boot["bootstrap.sh / bootstrap.ps1"] - boot --> fetch["hub tarball fetch, no git needed"] - fetch --> upgrade["upgrade host packages"] - upgrade --> tools["install-tools: the host toolchain, floors in spec/host-tools.json"] - tools --> github["setup-github: identity, SSH signing, gh auth"] - github --> skillsinstall["install-skills: fleet skills for the user"] - skillsinstall --> safety["agent-safety install: write guard + CLAUDE.md blocks"] - safety --> verify["verify: host_gate + skills_install --report"] - verify --> session["first agent session"] -``` - -Owned by [`host-setup/`][host-setup-readme] and [`docs/host-setup.md`][host-setup-doc]. The tools step also polices its own PATH: a stray copy shadowing a managed `uv`, `jq`, or `git-restore-mtime` install is named in the report and removed by an install or upgrade run when removal is safe. No open gaps sit on this path: G1, the skills install with no home in the provisioning flow, is closed and its row records the resolution. - -### A New Repository - -An agent is told to create or stand up a repo. The router of last resort is the byte-locked `Fleet Bootstrap` section of [`AGENTS.md`][agents], mirrored host-wide by the `CLAUDE.md` block the agent-safety installer deploys, so the routing reaches an agent even in a directory holding nothing. - -The section-by-section flow is diagrammed at the top of [`STANDUP.md`][standup], and the routing into it in the `Fleet Bootstrap` section of [`AGENTS.md`][agents]. - -Owned by [`STANDUP.md`][standup], packaged as the `standup-a-repo` skill. No open gaps sit on this path: G2, the silent bare-run overlay skip, is closed and its row records the resolution. - -### A Stale Repository - -The hub has moved and a downstream repo is behind. The agent runs [`RESYNC.md`][resync] from a hub checkout, which runs [`AUDIT.md`][audit] end to end and applies the findings in a load-bearing order. - -The routing and the load-bearing apply order are diagrammed at the top of [`RESYNC.md`][resync], the deletion triage in its section 4, and the audit pipeline with its verdicts in [`AUDIT.md`][audit]. - -Owned by [`RESYNC.md`][resync] and [`AUDIT.md`][audit], packaged as the `resync-a-repo` skill with the `carried-instruction-file-guard` and `copilot-instructions-keeper` skills firing inside it. No open gaps sit on this path: G3, the audit-to-install bridge, G4, the prose-deletion sweep, and G5, the intent-staleness advisory, are closed and their rows record the resolutions. - -### Daily Development in a Conformant Repository - -The steady state. An agent writes Python, C#, shell, or config in a repo that already conforms, and the codestyle, commit, and review skills fire by trigger. - -```mermaid -flowchart TD - start["session start"] -->|"restated-rule symptom"| stale["skills_install --report, per the documented cadence"] - start --> work["work: codestyle, commit, and doc skills fire by trigger"] - work --> gates["pre-commit gates: prose lint, eol"] - gates --> pr["pull request"] - pr --> review["Copilot loop via pr_review.py"] - review --> merge["merge per registry workflowModel"] -``` - -Owned by the per-language sections of [`CODESTYLE.md`][codestyle] and the conduct skills. The gates node is the [`OPERATIONS.md`][operations] "Run the gates the way CI runs them" section, and the review node runs through [`scripts/pr_review.py`][pr-review]. No open gaps sit on this path: G6, the unwired staleness check, and G7, the operational direct-commit allowance, are closed and their rows record the resolutions. - -### Hub-Side Operations - -Work on the fleet itself, run from a hub checkout: sweeping the fleet for drift, changing a carried rule, and changing the skills. - -```mermaid -flowchart LR - subgraph sweep["fleet sweep"] - reg["registry iterate"] --> peraudit["per-repo audit"] --> issues["convergence issues"] - end - subgraph lifecycle["skill lifecycle"] - author["edit .agents/skills/"] --> gen["build_dist.py"] --> check["CI runs --check"] --> prq["PR"] --> merged["merge"] --> refresh["hosts re-run installer"] - end - subgraph rollout["carried-change rollout"] - specedit["spec or law edit"] --> revendor["fleet re-vendor per RESYNC"] - end -``` - -Owned by [`AUDIT.md`][audit] section 10, [`GOVERNANCE.md` "Hub-Hosted Tooling"][governance-hub-hosted-tooling], and [`.agents/skills/README.md`][skills-readme]. The runnable form of this door is [`OPERATIONS.md`][operations]: its "Audit the fleet" and "Apply or verify repository configuration" runbooks drive [`spec/audit.py`][audit-py] and the `repo-config/` apply, and its "Run the gates the way CI runs them" section runs [`scripts/repo_gate.py`][repo-gate] beside the prose gates. The gaps this path carried (G9, G10, G12) are closed: the four skills below cover the topics, the lifecycle, and the conduct rules, and the register rows record the resolutions. - -## Skills Install Model - -**Resolved: the install is global per user, and the work is closing its gaps, not adding a second model.** A per-repo pinned install was considered and rejected: it would let a repo's skills match its own state, but it forfeits coverage of ad-hoc sessions in no repo at all (which is where the incidents this fleet guards against actually happened), doubles the staleness surface, and adds a version-resolution mechanism the fleet does not need while the whole fleet tracks one hub. - -The lifecycle chain as built: a skill is hand-authored under [`.agents/skills/`][skills-readme], [`scripts/build_dist.py`][build-dist] generates the Claude Code plugin under [`.claude-plugin/`][marketplace], and [`scripts/skills_install.py`][skills-install] installs both forms per machine (an overlay copy into `~/.agents/skills/` for Codex and opencode, a user-scope plugin install for Claude Code), stamping the hub commit into `~/.agents/skills-install-stamp.json`. `skills_install.py --report` is the read-only staleness check and exits non-zero when the machine is behind the checkout. - -Four wiring points close the model, and each is in place: - -1. **Bootstrap** (G1, closed): [`host-setup/bootstrap.sh`][bootstrap] and [`bootstrap.ps1`][bootstrap-ps1] end their host mode with a skills step, driven by the `install-skills` pair in the platform directories, degrading gracefully when the `claude` CLI is absent (the overlay half still lands, and the stamp records the partial install). Each loader hands the commit it resolved to the installer, so a stamp written from the tarball tree stays checkable. -2. **Host contract** (G1, closed): [`docs/host-setup.md`][host-setup-doc] states the install and the verify command in its "Fleet Skills Install" section, and the [`README.md`][readme] "Using This Repo" section names the skills install among its four deployed things. -3. **Session entry** (G6, closed): the tail of [`AGENTS.md`][agents] says a rule that keeps needing restating signals a stale install, and the `fleet-conformance-check` skill runs the report and states the cadence, so the symptom routes to the check without new tooling. -4. **Refresh cadence** (G6, closed): [`docs/host-setup.md`][host-setup-doc] "Fleet Skills Install" states it: re-run the installer when `--report` exits non-zero, and after any hub merge that touches `.agents/skills/`. The maintainer runs it by hand, and an automated refresh is deliberately out of scope until the fleet has evidence the manual cadence fails. - -## Gap Register - -| ID | Gap | Owner | Phase | -| --- | --- | --- | --- | -| G1 | Skills install is absent from the cold-start flow | script + doc | closed | -| G2 | Host-tools repo overlay is silently skippable | script + doc | closed | -| G3 | A failed tool floor names no install remedy | spec + script | closed | -| G4 | Deletion sweeps miss prose describing the deleted path | doc | closed | -| G5 | Intent-fidelity carried files have no drift detection | spec + decision | closed | -| G6 | Session entry never checks skill staleness | doc + skill | closed | -| G7 | Operational develop PR-only rule is prose-enforced | decision | closed | -| G8 | Generated plugin can ship stale with no CI gate | CI | closed | -| G9 | WORKFLOW.md and AUDIT.md have no skill coverage | skill | closed | -| G10 | The skill lifecycle itself has no skill | skill | closed | -| G11 | Peer messaging is live but undeclared | doc | closed | -| G12 | General conduct rules have no skill | skill | closed | - -Each gap's handoff below states who detects it, what closes it, and the test that proves it closed. The handoff sentence is the contract the closing pull request implements. - -### G1: Skills Install Is Absent From the Cold Start (Closed) - -- **Gap** - A host bootstrapped end to end via `host-setup/` had every tool and no fleet skills, because no provisioning step ran [`scripts/skills_install.py`][skills-install]. -- **Resolution** - The bootstrap host mode ends with a skills step: `install-skills.sh` and `install-skills.ps1` drive the installer from the fetched tree, a `--skills` action runs the step on its own, and the bootstrap report reads `--report` beside the other status lines. Each loader hands the commit it resolved to the installer via `SKILLS_SOURCE_COMMIT`, so the stamp written from a tarball tree stays checkable and `--report` exits zero on a fresh host, which is this row's closing test. [`docs/host-setup.md`][host-setup-doc] carries the "Fleet Skills Install" section with the verify line, and [`README.md`][readme] "Using This Repo" names four deployed things. -- **Decisions** - The `claude` CLI stays out of [`spec/host-tools.json`][host-tools]: a Codex-only machine is a complete machine, so the installer degrades where the CLI is absent, landing the overlay half and recording the partial install in the stamp. The skills step is the recorded exception to `host-setup/`'s no-Python and independent-fetchability rules, and it runs last in a stand-up so `install-tools` provides its interpreter first. -- **Cross-links** - [#671][issue-671] and [#673][issue-673] touch the same `host-setup/` scripts and stay open on their own tracks. - -### G2: Host-Tools Repo Overlay Is Silently Skippable (Closed) - -- **Gap** - [`scripts/host_gate.py`][host-gate] run without `--repo` read only the declaration at its own working directory, so a run started in a subdirectory of a repo carrying a `host-tools.json` overlay skipped that overlay without a word. -- **Resolution** - A bare run whose working directory sits inside such a repo prints a warning naming the overlay's directory and the `--repo` re-run that counts it, asserted by the `TestBareRunOverlayWarning` cases in `scripts/tests/test_host_gate.py`. An explicit `--repo` and `--no-local` each stay silent, since both are a choice the caller made. [`STANDUP.md`][standup] section 0 states the residual case the warning cannot cover, a target repo that does not exist yet, instead of the workaround sentence. - -### G3: A Failed Tool Floor Names No Install Remedy (Closed) - -- **Gap** - The tool catalog detected a stale tool, and the handoff back into `host-setup/` did not exist: a failed floor left the operator or agent to rediscover which installer provides the tool. -- **Resolution** - Each floored [`spec/host-tools.json`][host-tools] entry carries a per-platform `remedy` beside its `source`, and a below-floor failure prints it as a `REMEDY:` line, with a `host-setup/` path resolved against the checkout the gate runs from so the command is runnable as printed. The catalog's own note states the field's semantics. - -```mermaid -flowchart LR - fail["host_gate: tool below floor"] --> lookup["read remedy for the tool and platform"] - lookup --> remedy["output prints the install command"] - remedy --> run["operator or agent runs it"] - run --> recheck["re-run host_gate"] --> ok["proceed"] -``` - -- **Closing test** - [`scripts/tests/test_bootstrap.py`][test-bootstrap] asserts the mapping stays total per platform, with the one recorded not-applicable exception, and that a remedy handing back into an installer names a tool that installer manages. [`spec/validate.py`][validate] and the schema require a remedy on every hub floor. A repository overlay may still add a floor without one, in which case the failure degrades to the `INSTALL FROM:` source line. - -### G4: Deletion Sweeps Miss Prose (Closed) - -- **Gap** - A resync that deletes a carried file greps for the path and finds code uses, not prose describing the file without naming its path. A measured incident left a layout section describing a deleted script. -- **Resolution** - Split by what a pattern can reach. The named-path half is mechanized: the `dead-path` rule in [`scripts/prose_lint.py`][prose-lint] reports a Markdown mention (a backtick span, an inline link target, or a reference definition) of a path git once tracked and the tree no longer holds. Keying on deletion history is what scopes it: a proposed file a backlog names, another repository's layout, and a ref like `origin/develop` each have no history here and stay silent, and a manifest-declared carried path is exempt since the hub's own instance retires to a catalog snippet while docs keep naming the carried file. A hub-hosted path is exempt for the mirror-image reason, measured downstream rather than predicted: a repository that deleted its copy per a `retire` disposition still names the hub's, which is the pointer [`GOVERNANCE.md`][governance] "Hub-Hosted Tooling" requires of it, and the manifest exemption cannot reach that case because no repository carries `spec/files.json`. That set is a literal in the gate, since the prose-gate action can fetch the one file and no ledger beside it, and a test asserts it against the ledger's `retire` dispositions so a new retirement cannot land on one side only. The rule runs in the default set and in CI, where the checkout fetches full history because the rule stands down loudly in a shallow clone rather than pass blind. The name-shaped half, the description that names no path, is `accepted` as manual: no pattern reaches it, the same judgment the home-path rule records, so the [`RESYNC.md`][resync] section 4 read of the layout and operations sections stands and now names the lint beside it. -- **Closing test** - `TestDeadPath` in `scripts/tests/test_prose_lint.py`, including the shallow stand-down and the tree-clean assertion. The rule's first tree-wide run caught a real instance, [`docs/host-setup.md`][host-setup-doc] describing bind-mounts in the deleted `.devcontainer/` definitions, fixed by re-pointing at the catalog snippets. - -### G5: Intent-Fidelity Drift Is Invisible (Closed) - -- **Gap** - A carried file at `intent` fidelity was presence-checked only, so it could trail the hub by many revisions while the audit read clean. This class hid real drift before. -- **Resolution** - The advisory ships in [`spec/audit.py`][audit-py]: for each applicable intent unit the audit compares when the hub canonical and the repo copy each last changed, and a canonical changing later reports the copy as possibly trailing, at DRIFT and never failing. A copy content-identical to the canonical is skipped, being both current and the promotion candidate `spec/fidelity_honesty.py` exists to find. Measured on its first run: a fleet repo's carried `WORKFLOW.md`, `CODESTYLE.md`, and `.editorconfig` each reported as trailing hub changes made weeks earlier, findings no prior check produced. -- **Decisions** - The handoff imagined reporting the hub revision each unit was last reconciled against, and no reconciliation record exists anywhere, so the last-modified comparison is the implementable proxy and ships instead of inventing a per-repo stamp. The advisory therefore says possibly: a copy touched after the hub change without actually reconciling reads current, a blind spot stated in [`RESYNC.md`][resync] section 5, [`AUDIT.md`][audit], and [`spec/fidelity-model.md`][fidelity-model] rather than papered over. - -### G6: Session Entry Never Checks Skill Staleness (Closed) - -- **Gap** - A machine with stale or missing skills behaves like a machine that never installed them, and nothing at session entry said so. The symptom is a rule that keeps needing to be restated. -- **Resolution** - The cadence is stated in both places the row asked for. [`docs/host-setup.md`][host-setup-doc] "Fleet Skills Install" directs a re-run of the installer when `--report` exits non-zero and after any hub merge touching `.agents/skills/`, and the `fleet-conformance-check` skill carries the same cadence in its own "Refresh cadence" section, routing the restated-rule symptom to the report it already runs. No new tooling, by design: the trigger is suspicion, and session entry stays uninstrumented until the fleet has evidence the manual cadence fails. - -### G7: Operational Develop PR-Only Is Prose-Enforced (Closed) - -- **Gap** - [`repo-config/operational/develop.json`][repo-config-readme] carries deletion, non-fast-forward, and signature rules only, so nothing blocks a direct commit that skips the feature-branch instruction during a standup. -- **Resolution** - Closed `accepted`, mirroring the [`spec/divergences.json`][divergences] vocabulary, with the disposition recorded where the exposure is described: [`STANDUP.md`][standup] section 0B states it beside its uneven-protection warning. The allowance is the operational model's foundation per [`WORKFLOW.md`][workflow] "Branch Model", and a standup runs on a feature branch either way, so the exposed interval is the tail of a procedure the instruction already governs. -- **Decisions** - The considered alternative, applying the release `develop` payload during standup and swapping to the operational payload after the closing audit, was declined: it adds a swap step that can be forgotten and a window where the live ruleset contradicts the registry's declared model, buying mechanical enforcement only for an interval the instruction already covers. A forgotten swap would surface as a ruleset defect on the next audit, which is real, and so would the contradiction it spends the interim in. - -### G8: Generated Plugin Can Ship Stale (Closed) - -- **Gap** - `.claude-plugin/` is generated from `.agents/skills/`, and a merge that edits the source without re-running [`scripts/build_dist.py`][build-dist] would ship a plugin that no longer matches it. -- **Resolution** - [`.github/workflows/validate-task.yml`][validate-task] runs `build_dist.py --check` as its own step in the lint job, on every pull request, and the required aggregator check gates on that job. A PR desyncing the two trees therefore fails the required check, which is this row's closing test. -- **Provenance** - The step landed in [#676][pr-676], which predates this register's merge, so this row's original `Checked` claim was stale on arrival. Recording that here rather than silently deleting the row is the maintenance rule doing its job. - -### G9: WORKFLOW.md and AUDIT.md Have No Skill (Closed) - -- **Gap** - The largest law doc ([`WORKFLOW.md`][workflow], the D1-D9 contract) and the measurement procedure ([`AUDIT.md`][audit]) had no skill surface, while every other procedure and language did. Thirteen [`GOVERNANCE.md`][governance] sections were likewise doc-only. -- **Resolution** - The `workflow-ci-contract` and `audit-a-repo` skills package the two docs in the kept-authority shape (the doc keeps the full rules, the skill is the summary that routes into it). The [`AGENTS.md`][agents] rule map carries a disposition per section: `Workflow YAML Conventions` and the three conduct sections are annotated with their surfacing skill, and a paragraph after the table states why each remaining unannotated section is doc-only by decision, so absence reads as a choice rather than an oversight. Both closing tests hold: the skills ship, and the map carries the dispositions. -- **Provenance** - All four phase-2 skills shipped in one pull request at the maintainer's direction, superseding the one-pull-request-per-skill note this doc carried, with `skill-lifecycle` authored first inside it so the others follow its procedure. - -### G10: The Skill Lifecycle Has No Skill (Closed) - -- **Gap** - Authoring, changing, and retiring a skill was governed by scripts and scattered prose, so the agent most likely to get it wrong (one editing a skill) had no skill watching it. -- **Resolution** - The `skill-lifecycle` skill packages the pipeline (source-versus-generated split, `build_dist.py` regenerate and `--check`, installer and stamp semantics, the doc-packaging pattern, trigger-description conventions), and [`.agents/skills/README.md`][skills-readme] defers to it for procedure, which is this row's closing test. It was authored first in phase 2 so the other three skills follow its procedure. - -### G11: Peer Messaging Is Live but Undeclared (Closed) - -- **Gap** - Agent-to-agent messaging works and has produced real findings, and no committed doc stated its rules, so each session rediscovered the capability without its boundaries. -- **Resolution** - [`docs/peer-messaging.md`][peer-messaging] declares the method and its safety rules, hub-only, shipped beside this doc in the P0 pull request, and the [`TODO.md`][todo] "Peer Messaging Between Agents as a Declared Method" item resolves to it. The row's phase cell trailed its own closure until the P3 register pass, which is the maintenance rule doing its job. - -### G12: General Conduct Rules Have No Skill (Closed) - -- **Gap** - The conduct layer (ask when unsure, never assume, verification before claiming done, delegation and token discipline) lived in carried [`AGENTS.md`][agents] sections and doc-only GOVERNANCE sections, with no skill firing at the moments those rules are violated. -- **Resolution** - The `agent-conduct` skill ships with the narrow decision-moment triggers the proposal specifies (about to claim done, about to assume, a failure just surfaced a lesson), summarizing `Verification Discipline`, `Communicating with the User`, and `Durable Knowledge and Self-Improvement`, which keep the full rules and carry the surfacing pointer, while the carried AGENTS.md sections stay the always-on layer. - -## Proposed Skills - -Four skills close G9, G10, and G12, shipped through the [`.agents/skills/`][skills-readme] pipeline with `skill-lifecycle` authored first so the other three follow its procedure (in one pull request at the maintainer's direction, per the G9 provenance note). Scope and overlap were settled here before authoring, and each block below is the contract its skill implements. - -### audit-a-repo - -- **Scope** - Read-only measurement of a named repo ending in a committed report: the [`AUDIT.md`][audit] flow, the verdict taxonomy, what the deterministic runner covers and what stays hand-judged, and the rule that measuring never edits. -- **Trigger** - Asked to audit, measure, or verify conformance of a named repo, or to judge a conformance claim. -- **Packages** - [`AUDIT.md`][audit], which keeps authority. -- **Overlap** - Completes the triangle: `standup-a-repo` creates, `resync-a-repo` applies, and this measures. `fleet-conformance-check` stays the in-repo self-check with no named target. Each description disambiguates against the others, in the style the existing three already use. - -### workflow-ci-contract - -- **Scope** - The [`WORKFLOW.md`][workflow] behavioral contract: the D-guarantees, the seam contract, artifact lifecycle, NBGV versioning, validate-at-entry, and the per-type walkthroughs as references. -- **Trigger** - Writing or editing workflow YAML, adding or dropping a release target, or reasoning about why a publish did or did not fire. -- **Packages** - The YAML half of the pipeline. `operational-vs-release-workflow` keeps the git half (branching, promotion, publish policy), and the two descriptions state the split. -- **Overlap** - The source doc is large, so the skill is a summary plus binding rules with `references/` splits, the shape `comment-and-doc-style` already uses. - -### skill-lifecycle - -- **Scope** - Creating, changing, splitting, and retiring a skill: the source-vs-generated split, the regen and `--check` semantics of [`scripts/build_dist.py`][build-dist], the install and stamp semantics of [`scripts/skills_install.py`][skills-install], the doc-packaging pattern (summary in the law doc, full rules in the skill), and trigger-description conventions. -- **Trigger** - About to create or edit anything under `.agents/skills/` or `.claude-plugin/`. -- **Packages** - [`.agents/skills/README.md`][skills-readme] procedure content, which then defers to it. -- **Overlap** - None, and the absence was gap G10. Adjacent to `comment-and-doc-style` for SKILL.md prose only. - -### agent-conduct - -- **Scope** - The conduct rules with no skill surface: verification before claiming done, asking instead of assuming, recording a lesson when a failure surfaces one, and the delegation summary. -- **Trigger** - **Narrow, at decision moments**: about to claim work is done without having verified it, about to proceed on an assumption the user could cheaply confirm, or a failure just surfaced a durable lesson. Deliberately not always-on: the carried [`AGENTS.md`][agents] sections are the always-on layer, and an always-on conduct skill would duplicate them and spend the tokens the delegation rules exist to save. -- **Packages** - `Verification Discipline`, `Communicating with the User`, and `Durable Knowledge and Self-Improvement` from [`GOVERNANCE.md`][governance], which keep authority. -- **Overlap** - The commit, review, and doc skills each carry their own conduct rules already, and this skill points rather than restates where one of those owns the moment. - -## Peer Messaging - -Agent-to-agent messaging on one host is a working method with measured value, and its rules are declared in [`docs/peer-messaging.md`][peer-messaging]. The location decision from the [`TODO.md`][todo] item resolves to a hub-only doc first: the rules bind sessions on the maintainer's own hosts today, the transport cannot cross a machine boundary, and a carried GOVERNANCE section costs a fleet re-vendor for rules whose cross-host half is unverified. Promotion to a carried section or a skill is re-evaluated when cross-host messaging is verified or a downstream session demonstrably needed the rules and lacked them. - -## Simplified Technical English Evaluation - -Should agent-authored prose adopt ASD-STE100, a controlled language standard, or a lighter constrained house style? The criteria: does it improve agent instruction-following, does it compose with the enforcement that exists ([`scripts/prose_lint.py`][prose-lint] and the character-set and semicolon rules), what does it cost to author, and does its vocabulary fit a technical fleet. - -| Criterion | Full ASD-STE100 | Constrained house style | -| --- | --- | --- | -| Instruction-following | One-instruction-per-sentence and active voice measurably help | The same two properties, adoptable directly | -| Enforcement fit | The controlled dictionary is not lintable by the existing tooling | Each rule lands as a `prose_lint.py` check like the current ones | -| Authoring cost | Approved-word lookup on every sentence, for every author and agent | Marginal on top of the rules already enforced | -| Vocabulary | The approved general-word list excludes ordinary technical usage this fleet needs | Unrestricted vocabulary, restricted structure | - -**Recommendation**: adopt the structural half (short sentences, one instruction per sentence, active voice, imperative procedure steps) as house style, codified incrementally as `prose_lint.py` checks. Do not adopt the controlled dictionary. The existing rules already lean this way, so this is a direction confirmed rather than a new regime. - -**Decision**: adopted as recommended, the structural half as house style and no controlled dictionary. The first structural check is the `sentence-length` rule in [`scripts/prose_lint.py`][prose-lint], capped at ASD-STE100's 25-word descriptive limit. It ships opt-in like `sentence-split`, since at landing it counted 1686 over-cap sentences in the hub's own corpus. A default or CI gate would fail whole files nobody is editing. Promotion into `DEFAULT_RULES` or CI is a separate decision, taken if and when the corpus converges. The decision is recorded in `GOVERNANCE.md` "Documentation Style Conventions" under "Sentence Structure", with the full house-style rules in the `comment-and-doc-style` skill. The skill also states why the two unlintable halves stay authoring discipline. - -## Adoption and Operation Roadmap - -Design-doc first: this doc merges, then each unchecked item becomes an issue linking its register row, and the closing pull request edits the row per the maintenance rule. - -### P0: This Pull Request - -- [x] Fleet map and gap register committed (this doc) -- [x] Peer messaging declared ([`docs/peer-messaging.md`][peer-messaging], closes G11) -- [x] [`TODO.md`][todo] peer-messaging item resolved by pointer - -### P1: Close the Install Model - -- [x] G1 bootstrap skills step, host-setup section, README fourth deployed thing (cross-links the open host-tooling issues [#671][issue-671] and [#673][issue-673], which touch the same scripts) -- [x] G2 `host_gate.py` bare-run warning -- [x] G3 failed-floor remedy output -- [x] G6 staleness cadence wording -- [x] G8 `build_dist.py --check` in CI, found already in place via [#676][pr-676] and recorded closed - -### P2: Close the Skill Coverage - -- [x] G10 `skill-lifecycle` skill, authored first -- [x] G9 `audit-a-repo` skill -- [x] G9 `workflow-ci-contract` skill, with `references/` splits for the guarantee catalog and the test methodology -- [x] G12 `agent-conduct` skill, narrow decision-moment triggers per the proposal -- [x] G9 disposition sweep over the doc-only GOVERNANCE sections in the [`AGENTS.md`][agents] rule map - -### P3: Audit-Depth Decisions - -- [x] G4 disposition: the `dead-path` lint ships for the named-path half, the manual read stands for the name-shaped half -- [x] G5 disposition: the last-modified advisory ships in [`spec/audit.py`][audit-py] -- [x] G7 disposition: `accepted`, recorded in [`STANDUP.md`][standup] section 0B - -### P4: Steady State - -- [ ] Refresh cadence observed in practice, revisited if the manual cadence fails -- [ ] Register rows retired as they close, per the maintenance rule -- [ ] Peer-messaging promotion re-evaluated after cross-host verification -- [x] STE structural checks land in `prose_lint.py` incrementally: adopted, the first (`sentence-length`, opt-in) shipped, and the rest land on evidence per the decision above - -## Decision Ledger Cross-References - -[`TODO.md`][todo] stays the running backlog, and this register does not fork it. The peer-messaging item resolves to [`docs/peer-messaging.md`][peer-messaging] and section G11. The host-tooling cluster ([#671][issue-671], [#672][issue-672], [#673][issue-673]) touches the same `host-setup/` surface as G1 and G3, so those issues and the P1 items cross-link rather than duplicate. A future TODO entry about an adoption gap lands as a register row here instead, with TODO carrying only the pointer. The open issues [#699][issue-699] (a repo and worktree layout convention with its own skill) and [#700][issue-700] (Python tooling in CI with a scripts split) each touch a mapped seam and stay on their own tracks, cross-linked here rather than duplicated. - -<!-- Repo --> - -[agents]: ../AGENTS.md -[audit]: ../AUDIT.md -[audit-py]: ../spec/audit.py -[bootstrap]: ../host-setup/bootstrap.sh -[bootstrap-ps1]: ../host-setup/bootstrap.ps1 -[build-dist]: ../scripts/build_dist.py -[codestyle]: ../CODESTYLE.md -[divergences]: ../spec/divergences.json -[fidelity-model]: ../spec/fidelity-model.md -[files]: ../spec/files.json -[governance]: ../GOVERNANCE.md -[governance-durable-knowledge]: ../GOVERNANCE.md#durable-knowledge-and-self-improvement -[governance-hub-hosted-tooling]: ../GOVERNANCE.md#hub-hosted-tooling -[history]: ../HISTORY.md -[host-gate]: ../scripts/host_gate.py -[host-setup-doc]: ./host-setup.md -[host-setup-readme]: ../host-setup/README.md -[host-tools]: ../spec/host-tools.json -[marketplace]: ../.claude-plugin/marketplace.json -[operations]: ../OPERATIONS.md -[peer-messaging]: ./peer-messaging.md -[pr-review]: ../scripts/pr_review.py -[prose-lint]: ../scripts/prose_lint.py -[readme]: ../README.md -[repo-config-readme]: ../repo-config/README.md -[repo-gate]: ../scripts/repo_gate.py -[repos]: ../registry/repos.json -[resync]: ../RESYNC.md -[scripts-readme]: ../scripts/README.md -[skills-install]: ../scripts/skills_install.py -[skills-readme]: ../.agents/skills/README.md -[standup]: ../STANDUP.md -[test-bootstrap]: ../scripts/tests/test_bootstrap.py -[todo]: ../TODO.md -[validate]: ../spec/validate.py -[validate-task]: ../.github/workflows/validate-task.yml -[workflow]: ../WORKFLOW.md - -<!-- Issues and Pull Requests --> - -[issue-671]: https://github.com/ptr727/ProjectTemplate/issues/671 -[issue-672]: https://github.com/ptr727/ProjectTemplate/issues/672 -[issue-673]: https://github.com/ptr727/ProjectTemplate/issues/673 -[issue-699]: https://github.com/ptr727/ProjectTemplate/issues/699 -[issue-700]: https://github.com/ptr727/ProjectTemplate/issues/700 -[pr-676]: https://github.com/ptr727/ProjectTemplate/pull/676 +# Fleet Map and Gap Register (Hub-Only) + +The **map** of how a human or an agent enters the fleet system, the **register** of the gaps and loose ends between its parts, and the roadmap that closes them. This doc is **hub-only** and is not carried downstream, because it describes the hub's own seams rather than a fact about any one repo. It is a map and never the procedure: [`AGENTS.md`][agents], [`STANDUP.md`][standup], [`RESYNC.md`][resync], and [`AUDIT.md`][audit] keep authority over their flows, and where this doc and a procedure doc disagree, the procedure doc wins and this map is what needs fixing. + +**Maintenance rule.** A pull request that closes a register gap edits that gap's row and detail block in the same change, per [GOVERNANCE.md "Durable Knowledge and Self-Improvement"][governance-durable-knowledge]. A register describing a gap that is already closed is itself stale prose, so the register is only trustworthy while this rule holds. The same rule covers the map: a pull request that changes a mapped seam, or a flow this doc draws or points to, updates this doc in the same change. + +## Table of Contents <!-- omit from toc --> + +- [Scope and Non-Goals](#scope-and-non-goals) +- [System Map](#system-map) +- [Entry Points](#entry-points) + - [Pre-Agent Cold Start](#pre-agent-cold-start) + - [A New Repository](#a-new-repository) + - [A Stale Repository](#a-stale-repository) + - [Daily Development in a Conformant Repository](#daily-development-in-a-conformant-repository) + - [Hub-Side Operations](#hub-side-operations) +- [Skills Install Model](#skills-install-model) +- [Gap Register](#gap-register) +- [Proposed Skills](#proposed-skills) + - [audit-a-repo](#audit-a-repo) + - [workflow-ci-contract](#workflow-ci-contract) + - [skill-lifecycle](#skill-lifecycle) + - [agent-conduct](#agent-conduct) +- [Peer Messaging](#peer-messaging) +- [Simplified Technical English Evaluation](#simplified-technical-english-evaluation) +- [Adoption and Operation Roadmap](#adoption-and-operation-roadmap) + - [P0: This Pull Request](#p0-this-pull-request) + - [P1: Close the Install Model](#p1-close-the-install-model) + - [P2: Close the Skill Coverage](#p2-close-the-skill-coverage) + - [P3: Audit-Depth Decisions](#p3-audit-depth-decisions) + - [P4: Steady State](#p4-steady-state) +- [Decision Ledger Cross-References](#decision-ledger-cross-references) + +## Scope and Non-Goals + +This doc governs five things: the entry-point routing map, the gap register with a defined handoff per gap, the resolved skills install model, the proposals for new skills, and the phased roadmap. It does not author skills (skills ship through the [`.agents/skills/`][skills-readme] pipeline in changes of their own), does not restate any procedure, and treats multi-agent coordination as a documented pattern only, with the rules in [`docs/peer-messaging.md`][peer-messaging]. The version history that produced the current skill-based model is in [`HISTORY.md`][history], so this doc states only what is. + +## System Map + +```mermaid +flowchart TD + agents["AGENTS.md router"] --> standup[STANDUP.md] + agents --> resync[RESYNC.md] + resync --> audit[AUDIT.md] + standup --> audit + law["GOVERNANCE.md / CODESTYLE.md / WORKFLOW.md"] --> agents + spec["spec/ + registry/"] --> audit + scripts["scripts/ hub-hosted tooling"] --> audit + skills[".agents/skills/ source"] --> build[build_dist.py] + build --> plugin[".claude-plugin/ generated"] + plugin --> install[skills_install.py] + skills --> install + install --> host["host: ~/.agents/skills + plugin + stamp"] + hostsetup["host-setup/ cold start"] --> host + ci["CI gates + prose-gate action"] --> law +``` + +The layers, one line each. The lifecycle docs route and procedure ([`AGENTS.md`][agents] routes, [`STANDUP.md`][standup] creates, [`RESYNC.md`][resync] re-lines, [`AUDIT.md`][audit] measures). The law docs hold the rules ([`GOVERNANCE.md`][governance] cross-cutting, [`CODESTYLE.md`][codestyle] per language, [`WORKFLOW.md`][workflow] the CI/CD contract). The machine ground truth is [`spec/`][files] plus [`registry/repos.json`][repos]. The hub-hosted tooling is [`scripts/`][scripts-readme], reached rather than carried. The skills source of truth is [`.agents/skills/`][skills-readme], and [`scripts/build_dist.py`][build-dist] generates the Claude Code plugin under [`.claude-plugin/`][marketplace] from it. [`scripts/skills_install.py`][skills-install] installs both forms per machine. [`host-setup/`][host-setup-doc] provisions a host from a stock OS. CI enforces the deterministic subset of the rules on every pull request. + +## Entry Points + +Five doors into the system. Each subsection names its flow, the docs that own it, and the closed register gaps that sit on its path. Where the owning procedure doc draws its own flow diagram, that diagram keeps authority and this map points to it rather than carrying a copy that can drift. The diagrams below draw only the flows no procedure doc draws. + +### Pre-Agent Cold Start + +A fresh OS install, no git, no agent. The operator copy-pastes the loader lines from [`host-setup/README.md`][host-setup-readme], and everything after that is scripted. No agent exists at this stage, so this path must work from prose and copy-paste alone. + +```mermaid +flowchart TD + os["stock OS install"] --> paste["copy-paste loader lines"] + paste --> boot["bootstrap.sh / bootstrap.ps1"] + boot --> fetch["hub tarball fetch, no git needed"] + fetch --> upgrade["upgrade host packages"] + upgrade --> tools["install-tools: the host toolchain, floors in spec/host-tools.json"] + tools --> github["setup-github: identity, SSH signing, gh auth"] + github --> skillsinstall["install-skills: fleet skills for the user"] + skillsinstall --> safety["agent-safety install: write guard + CLAUDE.md blocks"] + safety --> verify["verify: host_gate + skills_install --report"] + verify --> session["first agent session"] +``` + +Owned by [`host-setup/`][host-setup-readme] and [`docs/host-setup.md`][host-setup-doc]. The tools step also polices its own PATH: a stray copy shadowing a managed `uv`, `jq`, or `git-restore-mtime` install is named in the report and removed by an install or upgrade run when removal is safe. No open gaps sit on this path: G1, the skills install with no home in the provisioning flow, is closed and its row records the resolution. + +### A New Repository + +An agent is told to create or stand up a repo. The router of last resort is the byte-locked `Fleet Bootstrap` section of [`AGENTS.md`][agents], mirrored host-wide by the `CLAUDE.md` block the agent-safety installer deploys, so the routing reaches an agent even in a directory holding nothing. + +The section-by-section flow is diagrammed at the top of [`STANDUP.md`][standup], and the routing into it in the `Fleet Bootstrap` section of [`AGENTS.md`][agents]. + +Owned by [`STANDUP.md`][standup], packaged as the `standup-a-repo` skill. No open gaps sit on this path: G2, the silent bare-run overlay skip, is closed and its row records the resolution. + +### A Stale Repository + +The hub has moved and a downstream repo is behind. The agent runs [`RESYNC.md`][resync] from a hub checkout, which runs [`AUDIT.md`][audit] end to end and applies the findings in a load-bearing order. + +The routing and the load-bearing apply order are diagrammed at the top of [`RESYNC.md`][resync], the deletion triage in its section 4, and the audit pipeline with its verdicts in [`AUDIT.md`][audit]. + +Owned by [`RESYNC.md`][resync] and [`AUDIT.md`][audit], packaged as the `resync-a-repo` skill with the `carried-instruction-file-guard` and `copilot-instructions-keeper` skills firing inside it. No open gaps sit on this path: G3, the audit-to-install bridge, G4, the prose-deletion sweep, and G5, the intent-staleness advisory, are closed and their rows record the resolutions. + +### Daily Development in a Conformant Repository + +The steady state. An agent writes Python, C#, shell, or config in a repo that already conforms, and the codestyle, commit, and review skills fire by trigger. + +```mermaid +flowchart TD + start["session start"] -->|"restated-rule symptom"| stale["skills_install --report, per the documented cadence"] + start --> work["work: codestyle, commit, and doc skills fire by trigger"] + work --> gates["pre-commit gates: prose lint, eol"] + gates --> pr["pull request"] + pr --> review["Copilot loop via pr_review.py"] + review --> merge["merge per registry workflowModel"] +``` + +Owned by the per-language sections of [`CODESTYLE.md`][codestyle] and the conduct skills. The gates node is the [`OPERATIONS.md`][operations] "Run the gates the way CI runs them" section, and the review node runs through [`scripts/pr_review.py`][pr-review]. No open gaps sit on this path: G6, the unwired staleness check, and G7, the operational direct-commit allowance, are closed and their rows record the resolutions. + +### Hub-Side Operations + +Work on the fleet itself, run from a hub checkout: sweeping the fleet for drift, changing a carried rule, and changing the skills. + +```mermaid +flowchart LR + subgraph sweep["fleet sweep"] + reg["registry iterate"] --> peraudit["per-repo audit"] --> issues["convergence issues"] + end + subgraph lifecycle["skill lifecycle"] + author["edit .agents/skills/"] --> gen["build_dist.py"] --> check["CI runs --check"] --> prq["PR"] --> merged["merge"] --> refresh["hosts re-run installer"] + end + subgraph rollout["carried-change rollout"] + specedit["spec or law edit"] --> revendor["fleet re-vendor per RESYNC"] + end +``` + +Owned by [`AUDIT.md`][audit] section 10, [`GOVERNANCE.md` "Hub-Hosted Tooling"][governance-hub-hosted-tooling], and [`.agents/skills/README.md`][skills-readme]. The runnable form of this door is [`OPERATIONS.md`][operations]: its "Audit the fleet" and "Apply or verify repository configuration" runbooks drive [`spec/audit.py`][audit-py] and the `repo-config/` apply, and its "Run the gates the way CI runs them" section runs [`scripts/repo_gate.py`][repo-gate] beside the prose gates. The gaps this path carried (G9, G10, G12) are closed: the four skills below cover the topics, the lifecycle, and the conduct rules, and the register rows record the resolutions. + +## Skills Install Model + +**Resolved: the install is global per user, and the work is closing its gaps, not adding a second model.** A per-repo pinned install was considered and rejected: it would let a repo's skills match its own state, but it forfeits coverage of ad-hoc sessions in no repo at all (which is where the incidents this fleet guards against actually happened), doubles the staleness surface, and adds a version-resolution mechanism the fleet does not need while the whole fleet tracks one hub. + +The lifecycle chain as built: a skill is hand-authored under [`.agents/skills/`][skills-readme], [`scripts/build_dist.py`][build-dist] generates the Claude Code plugin under [`.claude-plugin/`][marketplace], and [`scripts/skills_install.py`][skills-install] installs both forms per machine (an overlay copy into `~/.agents/skills/` for Codex and opencode, a user-scope plugin install for Claude Code), stamping the hub commit into `~/.agents/skills-install-stamp.json`. `skills_install.py --report` is the read-only staleness check and exits non-zero when the machine is behind the checkout. + +Four wiring points close the model, and each is in place: + +1. **Bootstrap** (G1, closed): [`host-setup/bootstrap.sh`][bootstrap] and [`bootstrap.ps1`][bootstrap-ps1] end their host mode with a skills step, driven by the `install-skills` pair in the platform directories, degrading gracefully when the `claude` CLI is absent (the overlay half still lands, and the stamp records the partial install). Each loader hands the commit it resolved to the installer, so a stamp written from the tarball tree stays checkable. +2. **Host contract** (G1, closed): [`docs/host-setup.md`][host-setup-doc] states the install and the verify command in its "Fleet Skills Install" section, and the [`README.md`][readme] "Using This Repo" section names the skills install among its four deployed things. +3. **Session entry** (G6, closed): the tail of [`AGENTS.md`][agents] says a rule that keeps needing restating signals a stale install, and the `fleet-conformance-check` skill runs the report and states the cadence, so the symptom routes to the check without new tooling. +4. **Refresh cadence** (G6, closed): [`docs/host-setup.md`][host-setup-doc] "Fleet Skills Install" states it: re-run the installer when `--report` exits non-zero, and after any hub merge that touches `.agents/skills/`. The maintainer runs it by hand, and an automated refresh is deliberately out of scope until the fleet has evidence the manual cadence fails. + +## Gap Register + +| ID | Gap | Owner | Phase | +| --- | --- | --- | --- | +| G1 | Skills install is absent from the cold-start flow | script + doc | closed | +| G2 | Host-tools repo overlay is silently skippable | script + doc | closed | +| G3 | A failed tool floor names no install remedy | spec + script | closed | +| G4 | Deletion sweeps miss prose describing the deleted path | doc | closed | +| G5 | Intent-fidelity carried files have no drift detection | spec + decision | closed | +| G6 | Session entry never checks skill staleness | doc + skill | closed | +| G7 | Operational develop PR-only rule is prose-enforced | decision | closed | +| G8 | Generated plugin can ship stale with no CI gate | CI | closed | +| G9 | WORKFLOW.md and AUDIT.md have no skill coverage | skill | closed | +| G10 | The skill lifecycle itself has no skill | skill | closed | +| G11 | Peer messaging is live but undeclared | doc | closed | +| G12 | General conduct rules have no skill | skill | closed | + +Each gap's handoff below states who detects it, what closes it, and the test that proves it closed. The handoff sentence is the contract the closing pull request implements. + +### G1: Skills Install Is Absent From the Cold Start (Closed) + +- **Gap** - A host bootstrapped end to end via `host-setup/` had every tool and no fleet skills, because no provisioning step ran [`scripts/skills_install.py`][skills-install]. +- **Resolution** - The bootstrap host mode ends with a skills step: `install-skills.sh` and `install-skills.ps1` drive the installer from the fetched tree, a `--skills` action runs the step on its own, and the bootstrap report reads `--report` beside the other status lines. Each loader hands the commit it resolved to the installer via `SKILLS_SOURCE_COMMIT`, so the stamp written from a tarball tree stays checkable and `--report` exits zero on a fresh host, which is this row's closing test. [`docs/host-setup.md`][host-setup-doc] carries the "Fleet Skills Install" section with the verify line, and [`README.md`][readme] "Using This Repo" names four deployed things. +- **Decisions** - The `claude` CLI stays out of [`spec/host-tools.json`][host-tools]: a Codex-only machine is a complete machine, so the installer degrades where the CLI is absent, landing the overlay half and recording the partial install in the stamp. The skills step is the recorded exception to `host-setup/`'s no-Python and independent-fetchability rules, and it runs last in a stand-up so `install-tools` provides its interpreter first. +- **Cross-links** - [#671][issue-671] and [#673][issue-673] touch the same `host-setup/` scripts and stay open on their own tracks. + +### G2: Host-Tools Repo Overlay Is Silently Skippable (Closed) + +- **Gap** - [`scripts/host_gate.py`][host-gate] run without `--repo` read only the declaration at its own working directory, so a run started in a subdirectory of a repo carrying a `host-tools.json` overlay skipped that overlay without a word. +- **Resolution** - A bare run whose working directory sits inside such a repo prints a warning naming the overlay's directory and the `--repo` re-run that counts it, asserted by the `TestBareRunOverlayWarning` cases in `scripts/tests/test_host_gate.py`. An explicit `--repo` and `--no-local` each stay silent, since both are a choice the caller made. [`STANDUP.md`][standup] section 0 states the residual case the warning cannot cover, a target repo that does not exist yet, instead of the workaround sentence. + +### G3: A Failed Tool Floor Names No Install Remedy (Closed) + +- **Gap** - The tool catalog detected a stale tool, and the handoff back into `host-setup/` did not exist: a failed floor left the operator or agent to rediscover which installer provides the tool. +- **Resolution** - Each floored [`spec/host-tools.json`][host-tools] entry carries a per-platform `remedy` beside its `source`, and a below-floor failure prints it as a `REMEDY:` line, with a `host-setup/` path resolved against the checkout the gate runs from so the command is runnable as printed. The catalog's own note states the field's semantics. + +```mermaid +flowchart LR + fail["host_gate: tool below floor"] --> lookup["read remedy for the tool and platform"] + lookup --> remedy["output prints the install command"] + remedy --> run["operator or agent runs it"] + run --> recheck["re-run host_gate"] --> ok["proceed"] +``` + +- **Closing test** - [`scripts/tests/test_bootstrap.py`][test-bootstrap] asserts the mapping stays total per platform, with the one recorded not-applicable exception, and that a remedy handing back into an installer names a tool that installer manages. [`spec/validate.py`][validate] and the schema require a remedy on every hub floor. A repository overlay may still add a floor without one, in which case the failure degrades to the `INSTALL FROM:` source line. + +### G4: Deletion Sweeps Miss Prose (Closed) + +- **Gap** - A resync that deletes a carried file greps for the path and finds code uses, not prose describing the file without naming its path. A measured incident left a layout section describing a deleted script. +- **Resolution** - Split by what a pattern can reach. The named-path half is mechanized: the `dead-path` rule in [`scripts/prose_lint.py`][prose-lint] reports a Markdown mention (a backtick span, an inline link target, or a reference definition) of a path git once tracked and the tree no longer holds. Keying on deletion history is what scopes it: a proposed file a backlog names, another repository's layout, and a ref like `origin/develop` each have no history here and stay silent, and a manifest-declared carried path is exempt since the hub's own instance retires to a catalog snippet while docs keep naming the carried file. A hub-hosted path is exempt for the mirror-image reason, measured downstream rather than predicted: a repository that deleted its copy per a `retire` disposition still names the hub's, which is the pointer [`GOVERNANCE.md`][governance] "Hub-Hosted Tooling" requires of it, and the manifest exemption cannot reach that case because no repository carries `spec/files.json`. That set is a literal in the gate, since the prose-gate action can fetch the one file and no ledger beside it, and a test asserts it against the ledger's `retire` dispositions so a new retirement cannot land on one side only. The rule runs in the default set and in CI, where the checkout fetches full history because the rule stands down loudly in a shallow clone rather than pass blind. The name-shaped half, the description that names no path, is `accepted` as manual: no pattern reaches it, the same judgment the home-path rule records, so the [`RESYNC.md`][resync] section 4 read of the layout and operations sections stands and now names the lint beside it. +- **Closing test** - `TestDeadPath` in `scripts/tests/test_prose_lint.py`, including the shallow stand-down and the tree-clean assertion. The rule's first tree-wide run caught a real instance, [`docs/host-setup.md`][host-setup-doc] describing bind-mounts in the deleted `.devcontainer/` definitions, fixed by re-pointing at the catalog snippets. + +### G5: Intent-Fidelity Drift Is Invisible (Closed) + +- **Gap** - A carried file at `intent` fidelity was presence-checked only, so it could trail the hub by many revisions while the audit read clean. This class hid real drift before. +- **Resolution** - The advisory ships in [`spec/audit.py`][audit-py]: for each applicable intent unit the audit compares when the hub canonical and the repo copy each last changed, and a canonical changing later reports the copy as possibly trailing, at DRIFT and never failing. A copy content-identical to the canonical is skipped, being both current and the promotion candidate `spec/fidelity_honesty.py` exists to find. Measured on its first run: a fleet repo's carried `WORKFLOW.md`, `CODESTYLE.md`, and `.editorconfig` each reported as trailing hub changes made weeks earlier, findings no prior check produced. +- **Decisions** - The handoff imagined reporting the hub revision each unit was last reconciled against, and no reconciliation record exists anywhere, so the last-modified comparison is the implementable proxy and ships instead of inventing a per-repo stamp. The advisory therefore says possibly: a copy touched after the hub change without actually reconciling reads current, a blind spot stated in [`RESYNC.md`][resync] section 5, [`AUDIT.md`][audit], and [`spec/fidelity-model.md`][fidelity-model] rather than papered over. + +### G6: Session Entry Never Checks Skill Staleness (Closed) + +- **Gap** - A machine with stale or missing skills behaves like a machine that never installed them, and nothing at session entry said so. The symptom is a rule that keeps needing to be restated. +- **Resolution** - The cadence is stated in both places the row asked for. [`docs/host-setup.md`][host-setup-doc] "Fleet Skills Install" directs a re-run of the installer when `--report` exits non-zero and after any hub merge touching `.agents/skills/`, and the `fleet-conformance-check` skill carries the same cadence in its own "Refresh cadence" section, routing the restated-rule symptom to the report it already runs. No new tooling, by design: the trigger is suspicion, and session entry stays uninstrumented until the fleet has evidence the manual cadence fails. + +### G7: Operational Develop PR-Only Is Prose-Enforced (Closed) + +- **Gap** - [`repo-config/operational/develop.json`][repo-config-readme] carries deletion, non-fast-forward, and signature rules only, so nothing blocks a direct commit that skips the feature-branch instruction during a standup. +- **Resolution** - Closed `accepted`, mirroring the [`spec/divergences.json`][divergences] vocabulary, with the disposition recorded where the exposure is described: [`STANDUP.md`][standup] section 0B states it beside its uneven-protection warning. The allowance is the operational model's foundation per [`WORKFLOW.md`][workflow] "Branch Model", and a standup runs on a feature branch either way, so the exposed interval is the tail of a procedure the instruction already governs. +- **Decisions** - The considered alternative, applying the release `develop` payload during standup and swapping to the operational payload after the closing audit, was declined: it adds a swap step that can be forgotten and a window where the live ruleset contradicts the registry's declared model, buying mechanical enforcement only for an interval the instruction already covers. A forgotten swap would surface as a ruleset defect on the next audit, which is real, and so would the contradiction it spends the interim in. + +### G8: Generated Plugin Can Ship Stale (Closed) + +- **Gap** - `.claude-plugin/` is generated from `.agents/skills/`, and a merge that edits the source without re-running [`scripts/build_dist.py`][build-dist] would ship a plugin that no longer matches it. +- **Resolution** - [`.github/workflows/validate-task.yml`][validate-task] runs `build_dist.py --check` as its own step in the lint job, on every pull request, and the required aggregator check gates on that job. A PR desyncing the two trees therefore fails the required check, which is this row's closing test. +- **Provenance** - The step landed in [#676][pr-676], which predates this register's merge, so this row's original `Checked` claim was stale on arrival. Recording that here rather than silently deleting the row is the maintenance rule doing its job. + +### G9: WORKFLOW.md and AUDIT.md Have No Skill (Closed) + +- **Gap** - The largest law doc ([`WORKFLOW.md`][workflow], the D1-D9 contract) and the measurement procedure ([`AUDIT.md`][audit]) had no skill surface, while every other procedure and language did. Thirteen [`GOVERNANCE.md`][governance] sections were likewise doc-only. +- **Resolution** - The `workflow-ci-contract` and `audit-a-repo` skills package the two docs in the kept-authority shape (the doc keeps the full rules, the skill is the summary that routes into it). The [`AGENTS.md`][agents] rule map carries a disposition per section: `Workflow YAML Conventions` and the three conduct sections are annotated with their surfacing skill, and a paragraph after the table states why each remaining unannotated section is doc-only by decision, so absence reads as a choice rather than an oversight. Both closing tests hold: the skills ship, and the map carries the dispositions. +- **Provenance** - All four phase-2 skills shipped in one pull request at the maintainer's direction, superseding the one-pull-request-per-skill note this doc carried, with `skill-lifecycle` authored first inside it so the others follow its procedure. + +### G10: The Skill Lifecycle Has No Skill (Closed) + +- **Gap** - Authoring, changing, and retiring a skill was governed by scripts and scattered prose, so the agent most likely to get it wrong (one editing a skill) had no skill watching it. +- **Resolution** - The `skill-lifecycle` skill packages the pipeline (source-versus-generated split, `build_dist.py` regenerate and `--check`, installer and stamp semantics, the doc-packaging pattern, trigger-description conventions), and [`.agents/skills/README.md`][skills-readme] defers to it for procedure, which is this row's closing test. It was authored first in phase 2 so the other three skills follow its procedure. + +### G11: Peer Messaging Is Live but Undeclared (Closed) + +- **Gap** - Agent-to-agent messaging works and has produced real findings, and no committed doc stated its rules, so each session rediscovered the capability without its boundaries. +- **Resolution** - [`docs/peer-messaging.md`][peer-messaging] declares the method and its safety rules, hub-only, shipped beside this doc in the P0 pull request, and the [`TODO.md`][todo] "Peer Messaging Between Agents as a Declared Method" item resolves to it. The row's phase cell trailed its own closure until the P3 register pass, which is the maintenance rule doing its job. + +### G12: General Conduct Rules Have No Skill (Closed) + +- **Gap** - The conduct layer (ask when unsure, never assume, verification before claiming done, delegation and token discipline) lived in carried [`AGENTS.md`][agents] sections and doc-only GOVERNANCE sections, with no skill firing at the moments those rules are violated. +- **Resolution** - The `agent-conduct` skill ships with the narrow decision-moment triggers the proposal specifies (about to claim done, about to assume, a failure just surfaced a lesson), summarizing `Verification Discipline`, `Communicating with the User`, and `Durable Knowledge and Self-Improvement`, which keep the full rules and carry the surfacing pointer, while the carried AGENTS.md sections stay the always-on layer. + +## Proposed Skills + +Four skills close G9, G10, and G12, shipped through the [`.agents/skills/`][skills-readme] pipeline with `skill-lifecycle` authored first so the other three follow its procedure (in one pull request at the maintainer's direction, per the G9 provenance note). Scope and overlap were settled here before authoring, and each block below is the contract its skill implements. + +### audit-a-repo + +- **Scope** - Read-only measurement of a named repo ending in a committed report: the [`AUDIT.md`][audit] flow, the verdict taxonomy, what the deterministic runner covers and what stays hand-judged, and the rule that measuring never edits. +- **Trigger** - Asked to audit, measure, or verify conformance of a named repo, or to judge a conformance claim. +- **Packages** - [`AUDIT.md`][audit], which keeps authority. +- **Overlap** - Completes the triangle: `standup-a-repo` creates, `resync-a-repo` applies, and this measures. `fleet-conformance-check` stays the in-repo self-check with no named target. Each description disambiguates against the others, in the style the existing three already use. + +### workflow-ci-contract + +- **Scope** - The [`WORKFLOW.md`][workflow] behavioral contract: the D-guarantees, the seam contract, artifact lifecycle, NBGV versioning, validate-at-entry, and the per-type walkthroughs as references. +- **Trigger** - Writing or editing workflow YAML, adding or dropping a release target, or reasoning about why a publish did or did not fire. +- **Packages** - The YAML half of the pipeline. `operational-vs-release-workflow` keeps the git half (branching, promotion, publish policy), and the two descriptions state the split. +- **Overlap** - The source doc is large, so the skill is a summary plus binding rules with `references/` splits, the shape `comment-and-doc-style` already uses. + +### skill-lifecycle + +- **Scope** - Creating, changing, splitting, and retiring a skill: the source-vs-generated split, the regen and `--check` semantics of [`scripts/build_dist.py`][build-dist], the install and stamp semantics of [`scripts/skills_install.py`][skills-install], the doc-packaging pattern (summary in the law doc, full rules in the skill), and trigger-description conventions. +- **Trigger** - About to create or edit anything under `.agents/skills/` or `.claude-plugin/`. +- **Packages** - [`.agents/skills/README.md`][skills-readme] procedure content, which then defers to it. +- **Overlap** - None, and the absence was gap G10. Adjacent to `comment-and-doc-style` for SKILL.md prose only. + +### agent-conduct + +- **Scope** - The conduct rules with no skill surface: verification before claiming done, asking instead of assuming, recording a lesson when a failure surfaces one, and the delegation summary. +- **Trigger** - **Narrow, at decision moments**: about to claim work is done without having verified it, about to proceed on an assumption the user could cheaply confirm, or a failure just surfaced a durable lesson. Deliberately not always-on: the carried [`AGENTS.md`][agents] sections are the always-on layer, and an always-on conduct skill would duplicate them and spend the tokens the delegation rules exist to save. +- **Packages** - `Verification Discipline`, `Communicating with the User`, and `Durable Knowledge and Self-Improvement` from [`GOVERNANCE.md`][governance], which keep authority. +- **Overlap** - The commit, review, and doc skills each carry their own conduct rules already, and this skill points rather than restates where one of those owns the moment. + +## Peer Messaging + +Agent-to-agent messaging on one host is a working method with measured value, and its rules are declared in [`docs/peer-messaging.md`][peer-messaging]. The location decision from the [`TODO.md`][todo] item resolves to a hub-only doc first: the rules bind sessions on the maintainer's own hosts today, the transport cannot cross a machine boundary, and a carried GOVERNANCE section costs a fleet re-vendor for rules whose cross-host half is unverified. Promotion to a carried section or a skill is re-evaluated when cross-host messaging is verified or a downstream session demonstrably needed the rules and lacked them. + +## Simplified Technical English Evaluation + +Should agent-authored prose adopt ASD-STE100, a controlled language standard, or a lighter constrained house style? The criteria: does it improve agent instruction-following, does it compose with the enforcement that exists ([`scripts/prose_lint.py`][prose-lint] and the character-set and semicolon rules), what does it cost to author, and does its vocabulary fit a technical fleet. + +| Criterion | Full ASD-STE100 | Constrained house style | +| --- | --- | --- | +| Instruction-following | One-instruction-per-sentence and active voice measurably help | The same two properties, adoptable directly | +| Enforcement fit | The controlled dictionary is not lintable by the existing tooling | Each rule lands as a `prose_lint.py` check like the current ones | +| Authoring cost | Approved-word lookup on every sentence, for every author and agent | Marginal on top of the rules already enforced | +| Vocabulary | The approved general-word list excludes ordinary technical usage this fleet needs | Unrestricted vocabulary, restricted structure | + +**Recommendation**: adopt the structural half (short sentences, one instruction per sentence, active voice, imperative procedure steps) as house style, codified incrementally as `prose_lint.py` checks. Do not adopt the controlled dictionary. The existing rules already lean this way, so this is a direction confirmed rather than a new regime. + +**Decision**: adopted as recommended, the structural half as house style and no controlled dictionary. The first structural check is the `sentence-length` rule in [`scripts/prose_lint.py`][prose-lint], capped at ASD-STE100's 25-word descriptive limit. It ships opt-in like `sentence-split`, since at landing it counted 1686 over-cap sentences in the hub's own corpus. A default or CI gate would fail whole files nobody is editing. Promotion into `DEFAULT_RULES` or CI is a separate decision, taken if and when the corpus converges. The decision is recorded in `GOVERNANCE.md` "Documentation Style Conventions" under "Sentence Structure", with the full house-style rules in the `comment-and-doc-style` skill. The skill also states why the two unlintable halves stay authoring discipline. + +## Adoption and Operation Roadmap + +Design-doc first: this doc merges, then each unchecked item becomes an issue linking its register row, and the closing pull request edits the row per the maintenance rule. + +### P0: This Pull Request + +- [x] Fleet map and gap register committed (this doc) +- [x] Peer messaging declared ([`docs/peer-messaging.md`][peer-messaging], closes G11) +- [x] [`TODO.md`][todo] peer-messaging item resolved by pointer + +### P1: Close the Install Model + +- [x] G1 bootstrap skills step, host-setup section, README fourth deployed thing (cross-links the open host-tooling issues [#671][issue-671] and [#673][issue-673], which touch the same scripts) +- [x] G2 `host_gate.py` bare-run warning +- [x] G3 failed-floor remedy output +- [x] G6 staleness cadence wording +- [x] G8 `build_dist.py --check` in CI, found already in place via [#676][pr-676] and recorded closed + +### P2: Close the Skill Coverage + +- [x] G10 `skill-lifecycle` skill, authored first +- [x] G9 `audit-a-repo` skill +- [x] G9 `workflow-ci-contract` skill, with `references/` splits for the guarantee catalog and the test methodology +- [x] G12 `agent-conduct` skill, narrow decision-moment triggers per the proposal +- [x] G9 disposition sweep over the doc-only GOVERNANCE sections in the [`AGENTS.md`][agents] rule map + +### P3: Audit-Depth Decisions + +- [x] G4 disposition: the `dead-path` lint ships for the named-path half, the manual read stands for the name-shaped half +- [x] G5 disposition: the last-modified advisory ships in [`spec/audit.py`][audit-py] +- [x] G7 disposition: `accepted`, recorded in [`STANDUP.md`][standup] section 0B + +### P4: Steady State + +- [ ] Refresh cadence observed in practice, revisited if the manual cadence fails +- [ ] Register rows retired as they close, per the maintenance rule +- [ ] Peer-messaging promotion re-evaluated after cross-host verification +- [x] STE structural checks land in `prose_lint.py` incrementally: adopted, the first (`sentence-length`, opt-in) shipped, and the rest land on evidence per the decision above + +## Decision Ledger Cross-References + +[`TODO.md`][todo] stays the running backlog, and this register does not fork it. The peer-messaging item resolves to [`docs/peer-messaging.md`][peer-messaging] and section G11. The host-tooling cluster ([#671][issue-671], [#672][issue-672], [#673][issue-673]) touches the same `host-setup/` surface as G1 and G3, so those issues and the P1 items cross-link rather than duplicate. A future TODO entry about an adoption gap lands as a register row here instead, with TODO carrying only the pointer. The open issues [#699][issue-699] (a repo and worktree layout convention with its own skill) and [#700][issue-700] (Python tooling in CI with a scripts split) each touch a mapped seam and stay on their own tracks, cross-linked here rather than duplicated. + +<!-- Repo --> + +[agents]: ../AGENTS.md +[audit]: ../AUDIT.md +[audit-py]: ../spec/audit.py +[bootstrap]: ../host-setup/bootstrap.sh +[bootstrap-ps1]: ../host-setup/bootstrap.ps1 +[build-dist]: ../scripts/build_dist.py +[codestyle]: ../CODESTYLE.md +[divergences]: ../spec/divergences.json +[fidelity-model]: ../spec/fidelity-model.md +[files]: ../spec/files.json +[governance]: ../GOVERNANCE.md +[governance-durable-knowledge]: ../GOVERNANCE.md#durable-knowledge-and-self-improvement +[governance-hub-hosted-tooling]: ../GOVERNANCE.md#hub-hosted-tooling +[history]: ../HISTORY.md +[host-gate]: ../scripts/host_gate.py +[host-setup-doc]: ./host-setup.md +[host-setup-readme]: ../host-setup/README.md +[host-tools]: ../spec/host-tools.json +[marketplace]: ../.claude-plugin/marketplace.json +[operations]: ../OPERATIONS.md +[peer-messaging]: ./peer-messaging.md +[pr-review]: ../scripts/pr_review.py +[prose-lint]: ../scripts/prose_lint.py +[readme]: ../README.md +[repo-config-readme]: ../repo-config/README.md +[repo-gate]: ../scripts/repo_gate.py +[repos]: ../registry/repos.json +[resync]: ../RESYNC.md +[scripts-readme]: ../scripts/README.md +[skills-install]: ../scripts/skills_install.py +[skills-readme]: ../.agents/skills/README.md +[standup]: ../STANDUP.md +[test-bootstrap]: ../scripts/tests/test_bootstrap.py +[todo]: ../TODO.md +[validate]: ../spec/validate.py +[validate-task]: ../.github/workflows/validate-task.yml +[workflow]: ../WORKFLOW.md + +<!-- Issues and Pull Requests --> + +[issue-671]: https://github.com/ptr727/ProjectTemplate/issues/671 +[issue-672]: https://github.com/ptr727/ProjectTemplate/issues/672 +[issue-673]: https://github.com/ptr727/ProjectTemplate/issues/673 +[issue-699]: https://github.com/ptr727/ProjectTemplate/issues/699 +[issue-700]: https://github.com/ptr727/ProjectTemplate/issues/700 +[pr-676]: https://github.com/ptr727/ProjectTemplate/pull/676 diff --git a/docs/host-setup.md b/docs/host-setup.md index dcaffe4b..f69722bf 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -1,327 +1,326 @@ -# Host Setup - -Prerequisites for working with this repo locally, applied once per machine before opening the devcontainer or building outside one. - -Supported hosts: - -- **Linux** - both the devcontainer flow and the host-install flow. -- **macOS** - both the devcontainer flow and the host-install flow. -- **Windows** - the devcontainer flow requires **WSL2**, and native Windows (PowerShell + winget) is supported only for the host-install flow described in `README.md`. The bind-mounts in the `catalog/snippets/devcontainer/` definitions rely on POSIX paths and only work from Linux/macOS/WSL2. - -> **Shell assumptions in this doc**: every command snippet below assumes a **POSIX shell** (bash/zsh) and POSIX path conventions (`~/.ssh/...`, `mkdir -p`, `$(...)` command substitution), except where a block is marked `powershell`. Such a block is the **Windows-native** form of the step it sits in, meant to run in PowerShell rather than translated. On Windows, run the POSIX snippets from **WSL2** or **Git Bash**, since they will not work as-is in PowerShell or `cmd.exe`. The git config and `gh` commands are portable, and only the file and path manipulation differs by shell. - -## What a Host Must Provide - -This section is the **contract**: which tools a host needs and which repo procedure stops working without each one. It deliberately names no installer, because `winget`, `brew` and `apt` differ per platform while the requirement does not. Per-platform install commands are tracked separately, in [`host-setup/`][host-setup-dir], so this table stays true on every host. - -| Tool | Needed by | Present when | Floor | -| --- | --- | --- | --- | -| `git` | everything, and the identity and signing contract in [`STANDUP.md`][standup] step 0 | `git --version` | none | -| `gh` | the PR and review loop, `gh api` queries, `repo-config/configure.sh` | `gh --version` | **2.47.0**, measured | -| Python 3 | `scripts/` and `spec/` (standard library only, no packages to install) | `python3 --version`, or `py -3 --version` on native Windows | **3.13**, target | -| `jq` | the ruleset normalizer in `repo-config/configure.sh`, the ruleset diff in [`AUDIT.md`][audit] section 6, and payload regeneration | `jq --version` | **1.7**, target | -| `docker` | the four linters, which run as pinned images rather than local installs | `docker --version` | **29.6.2**, target | -| `uv` / `uvx` | coverage runs, and the Python toolchain (`ruff`, `pyright` or `mypy`) in a Python repo | `uv --version` | **0.12.2**, target | - -The **Floor** column exists because presence and sufficiency are different questions and the answer to the first was being read as the answer to the second. A tool below its floor still answers `--version`, so every other column reports it as fine while `scripts/host_gate.py` fails it. The kind is named beside the number, since a **measured** floor sits above a version known to break a documented procedure and gives a failing host a defect to point at, where a **target** floor names the version the repo's toolchain is configured for and does not. The next section carries the reasoning behind each one. - -Two consequences worth reading off the table rather than discovering later. **Python 3 needs no packages**, because every script here is standard library only, so a bare interpreter is enough. And **the linters need only `docker`**, not `node`, `dotnet` or a local `markdownlint`, since each runs as a pinned image, which is what keeps a local run and CI the same check. - -**The interpreter is not called `python3` everywhere.** On native Windows the installer registers `python`, `py` and `python3.13` but **not** `python3`, where that name instead resolves to a Microsoft Store alias stub that reports the interpreter as missing, so a correctly set-up host fails a `python3` check. Stock Debian is the mirror image, carrying `python3` and no bare `python`. Use `py -3` on native Windows and `python3` elsewhere, and prefer `python3` in any script that must run on both, since WSL2 shadows the Windows stub. - -A missing tool is a host gap, not a repo problem. Install it and re-run, rather than working around it in a repo. - -### Where a Tool Comes From, and How Old It May Be - -Presence is the weaker half of this contract. Both host defects this fleet has actually hit are **version** facts on a tool that is installed, answers `--version`, and looks healthy, so the table above cannot see either one. [`spec/host-tools.json`][host-tools] carries the floors as data and records the defect each one encodes, and [`scripts/host_gate.py`][host-gate] reads it. A floor is one of two kinds and names its own kind in the `why` it carries. A **measured** floor sits above a version known to break a documented procedure, which is what both `gh` and `git-restore-mtime` carry. A **target** floor names the version the repo's own toolchain is configured for, which is what `python3` carries at 3.13, where `pyproject.toml` sets ruff and mypy to that version, so a lower interpreter is unverified rather than known broken and the entry says exactly that. Everything else carries none, deliberately: a floor nobody can justify becomes a host failure nobody can act on. - -**`gh` must not come from the distribution's package on Linux.** This is the one place this document names a source, because here the source *is* the requirement rather than a convenience. The GitHub CLI maintainers state that the community-distributed `2.45.x` / `2.46.x` is **broken by deprecated GitHub APIs**, so install from the official apt repository at [cli.github.com][cli-install-link] and upgrade from there. Both `gh` limitations recorded in [`OPERATIONS.md`][operations] were observed on a host carrying a distribution `gh 2.46.0`, and both are the deprecation class that note describes. On **Windows** `winget` tracks upstream releases, and on macOS Homebrew does, so neither raises this hazard and neither needs a note of its own. - -**`git-restore-mtime` must not come from it either, where a repo uses it.** Debian and Ubuntu package **2022.12**, which shells out to `git whatchanged`. Current `git` refuses that without a hidden opt-in flag a caller cannot pass through, so the tool restores nothing, prints its ordinary statistics and **exits 0**. A deploy keyed on mtimes then ships a full copy and reports success. Take the upstream release from [git-tools][git-tools-link], or in CI the [action][git-restore-mtime-action-link] that vendors it. Note the direction of that interaction: a **newer** `git` is the trigger rather than the remedy, so a host old enough to still allow `whatchanged` hides the defect rather than avoiding it. No procedure in this repo needs the tool, so the gate declares it **optional** and skips it when absent. - -**`docker` must not come from the distribution's own package either, with one exception.** Debian and Ubuntu package `docker.io`, an older build that trails and conflicts with `docker-ce`, so [`host-setup/linux/install-tools.sh`][host-setup-dir] removes it and installs from Docker's own apt repository at [download.docker.com][docker-install-link] instead, the same shape it already uses for `gh` and `node`. The exception is a **WSL distribution**, where the only sanctioned source is Docker Desktop's own WSL integration (Settings, Resources, WSL integration, on the Windows side, reported read-only by [`setup-wsl.ps1`][host-setup-windows]) and a native install is refused outright, with no override: running `docker-ce` directly inside a WSL distribution risks a second engine beside Desktop's own. On native **Windows**, `winget` already tracks upstream Desktop releases, so neither hazard arises there. - -**`docker`'s floor is read from the engine, not from the CLI banner.** The `docker` on `PATH` inside a WSL distribution can be a separately packaged client that talks to Docker Desktop's engine over the integration socket, and the two then carry different versions: a host was recorded with a distribution client at `29.1.3` against an engine at `29.7.2`, so `docker --version` failed a floor the engine cleared comfortably. The gate therefore asks the daemon first, with `docker version --format '{{.Server.Version}}'`, and falls back to the `docker --version` banner only where that exits non-zero, which is what a stopped or unreachable daemon does. The **Present when** column above still names the banner, because presence is what it answers and a host with the daemon stopped still has `docker` installed. - -**The rest of the table takes the distribution's package, and one more does not.** `git` and the Python interpreter come from the distribution, because each keeps up well enough that a second source buys nothing and costs a repository to trust. `jq` is the same on a current Debian or Ubuntu, which carries a version at or above the floor, and the upstream release binary is the answer only where it does not. `uv` is published by its authors as a release archive and packaged by neither distribution, so upstream is the only source there is. Where a repository needs `node`, the distribution's package trails upstream by whole release lines, so it comes from the NodeSource repository on the line upstream currently carries as long term support. Where a repository needs `dotnet`, the distribution's feed is preferred where it carries an SDK and Microsoft's feed is the fallback, because mixing the two is what breaks a host rather than either one alone, and Microsoft's carries `amd64` only. - -Neither `node` nor `dotnet` is in the table above, deliberately: they serve the repositories that need them rather than the fleet contract, and a repository needing one declares it in a `host-tools.json` of its own, which [`scripts/host_gate.py`][host-gate] merges over this one. The merge tightens only, so a repository may raise a floor or add one and may not lower or remove one. - -**A host being stood up needs no Python.** The tooling under [`host-setup/`][host-setup-dir] is shell and PowerShell, deliberately, because requiring an interpreter to upgrade a package or install a tool would make the first step of standing a host up depend on the thing that step exists to provide. The Python floor above is a development requirement, meaning [`scripts/`][scripts-dir] and [`spec/`][spec-dir], and a host that only runs services never has to meet it. `bootstrap.sh` needs `curl` and `tar`, both of which a base install carries or can install without a network tool of its own. `bootstrap.ps1` needs only `tar.exe`, which has shipped with Windows since 1803, and installs its one further dependency, PowerShell 7, itself through `winget`. The one exception is the skills step at the end of a stand-up, which drives the Python installer in [`scripts/`][scripts-dir], and it runs last for exactly that reason: `install-tools` has provided the interpreter by then, and run alone on a host without one it stops and names the tools step as its prerequisite. - -**Standing a host up.** [`host-setup/`][host-setup-dir] carries the tooling that makes a host satisfy this contract, and its README is the usage. A host with nothing runs [`host-setup/bootstrap.sh`][bootstrap], which fetches this repository and runs that tooling from the fetched tree. A native Windows host with nothing runs [`host-setup/bootstrap.ps1`][bootstrap-ps1] the same way, which finds or installs PowerShell 7 before it fetches anything, since every script under [`host-setup/windows/`][host-setup-windows] requires it. Neither is called by [`scripts/host_gate.py`][host-gate] and neither calls it: the gate measures a host against the floors above, and the tooling is a remedy a person chooses when the gate reports a gap. - -A repository that needs more than the fleet does adds its own `host-tools.json` at its root, which the gate layers over the hub's. It may add a tool nobody else uses, raise a floor, or turn an optional tool required. It may **not** lower a floor or turn a required tool optional, since those edits retire a fleet check from inside the repository it protects, and the gate reports a rejected relaxation rather than dropping it. - -## Git Identity - -Configure your name and email, used for commit authorship. **The email is the committing account's GitHub `noreply` address, never a private, personal, or invented one**, per [GOVERNANCE.md "Git and Commit Rules"][governance-git-and-commit-rules], which owns the rule and states the fleet's value. A private address trips GitHub's email-privacy push protection (GH007), and an invented one pollutes history. - -```shell -git config --global user.name "Your Name" -git config --global user.email "<id>+<username>@users.noreply.github.com" -``` - -Set this **globally**, once per machine. Repositories inherit it, so a repo-local `user.email` is redundant where the global is right and a wrong identity where it is not. An agent standing up a repo verifies this configuration rather than setting it ([`STANDUP.md`][standup] step 0). - -## SSH Key - -Generate an Ed25519 SSH key for both authentication and commit signing. One key serves both roles. - -```shell -ssh-keygen -t ed25519 -C "you@example.com" -f ~/.ssh/id_ed25519 -``` - -Add the public key (`~/.ssh/id_ed25519.pub`) to GitHub twice: - -1. **Authentication key** - [GitHub -> Settings -> SSH and GPG keys -> New SSH key][keys-link], key type **Authentication Key**. -2. **Signing key** - same page, but **Signing Key** type. GitHub treats these independently even though it's the same public key. - -Test the auth key: - -```shell -ssh -T git@github.com -``` - -## SSH Config - -Tell SSH which key to use for `github.com`. Pick the snippet for your platform. - -### Linux / WSL2 - -```sshconfig -# ~/.ssh/config -Host github.com - HostName github.com - User git - IdentityFile ~/.ssh/id_ed25519 - IdentitiesOnly yes -``` - -Make sure ssh-agent is running and the key is loaded. On systemd-based distros: - -```shell -systemctl --user enable --now ssh-agent.socket -ssh-add ~/.ssh/id_ed25519 -``` - -For non-systemd shells, add to `~/.bashrc` or `~/.zshrc`. The check probes the agent for at least one loaded key, because `[ -z "$SSH_AUTH_SOCK" ]` alone would miss the case where `SSH_AUTH_SOCK` is set but points at a stale socket or a keyless agent: - -```shell -if [ -z "$SSH_AUTH_SOCK" ] || ! ssh-add -l >/dev/null 2>&1; then - eval "$(ssh-agent -s)" >/dev/null - ssh-add ~/.ssh/id_ed25519 2>/dev/null -fi -``` - -### macOS - -```sshconfig -# ~/.ssh/config -Host github.com - HostName github.com - User git - IdentityFile ~/.ssh/id_ed25519 - IdentitiesOnly yes - UseKeychain yes - AddKeysToAgent yes -``` - -Load the key into the macOS Keychain so it's available without re-entering the passphrase: - -```shell -ssh-add --apple-use-keychain ~/.ssh/id_ed25519 -``` - -## Allowed Signers File - -Required for SSH signature verification by `git verify-commit` and similar tools. Without it git can sign commits but not verify them locally. - -```shell -mkdir -p ~/.config/git -echo "$(git config --global user.email) namespaces=\"git\" $(cat ~/.ssh/id_ed25519.pub)" >> ~/.config/git/allowed_signers -git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers -``` - -## Configure Git for SSH Signing - -```shell -git config --global gpg.format ssh -git config --global user.signingkey ~/.ssh/id_ed25519.pub -git config --global commit.gpgsign true -git config --global tag.gpgsign true -``` - -See [SSH commit signing][ssh-signing] for verification steps and per-OS troubleshooting. - -## GitHub CLI - -Install [`gh`][cli-link] and authenticate. - -```shell -gh auth login --hostname github.com --git-protocol ssh -``` - -Choose the SSH key generated above when prompted. - -## Agent Write-Safety Kit - -Required on any host where an agent runs with the `gh` credentials logged in, and its own README calls it the first thing to deploy on a new system. Install it from this repo, since the installer is idempotent and safe to re-run to update: - -```shell -host-setup/agent-safety/install.sh # Linux, WSL, macOS -``` - -```powershell -.\host-setup\agent-safety\install.ps1 # Windows, and the .\ prefix is required -``` - -Both wrap one `install.py`, so every platform runs the same tested path. Restart Claude Code sessions on the machine afterward so the hook and the `CLAUDE.md` block load. Details, verification, and scope limits are in [`host-setup/agent-safety/README.md`][agent-safety]. - -This is a **host** control, not a repo one. The carried `GOVERNANCE.md` rules reach fleet repos only, while the hook and the `CLAUDE.md` block cover every session on the machine, including ad-hoc work in no project at all, which is where the incident behind the kit happened. - -### Granting a Write the Guard Denies - -The guard denies a `gh` write whose explicit target sits under an owner other than the checkout's `origin` owner, and the denial names `GH_WRITE_GUARD_ALLOW` as the way past it. That grant is the maintainer's to make, and making it is a deliberate act taken outside the session rather than something an agent does for itself once blocked. - -**The case that raises it is usually a fork.** `origin` is your own fork under your own owner, and `upstream` is the project it was forked from under someone else's. Everything aimed at the fork is in scope and never denies, and only the half that leaves the owner stops: filing an issue on the upstream, opening a pull request against it, or commenting on one there. The grant therefore names the upstream alone, and the fork needs no grant at all. That asymmetry is what a reader hits first, since half the session's writes succeed and the other half do not. - -**The grant goes in the checkout's `.claude/settings.local.json`, as an `env` block:** - -```json -{ - "env": { - "GH_WRITE_GUARD_ALLOW": "upstream-owner/upstream-repo second-owner/other-repo third-owner/*" - } -} -``` - -**The value is one string holding every grant, never a JSON array**, since the hook reads an environment variable and an environment variable is a string. The three tokens above are three separate grants: two naming one repository each, and `third-owner/*` granting every repository under that owner. - -Tokens are separated by **any run of whitespace or commas**, so `a/b c/d`, `a/b,c/d`, and `a/b, c/d` all parse to the same two grants and the choice is cosmetic. A token carrying no `/` is ignored, so a malformed entry grants nothing rather than granting everything, and it also fails silently, which is why the confirmation step below is worth running. Grant the narrowest thing that unblocks the work, since a repository grant does not extend to that owner's other repositories and that containment is the property worth keeping. - -**The grant is per checkout, not per host.** `.claude/settings.local.json` lives in the working tree and is git-ignored, so it applies to sessions started in that checkout and does not follow the agent into another repository's sessions. That is the intended scope: a grant made to file one upstream issue from one fork does not quietly become a standing permission everywhere. - -**Restart the session afterward.** The hook reads the value from the environment the session was launched with, which is what makes the channel one an agent cannot use on itself, and it is equally why a grant added to a live session does nothing until that session restarts. - -**Two forms look right and leave the write denied.** An inline `GH_WRITE_GUARD_ALLOW=owner/repo gh ...` prefix sets the environment of the `gh` process, and an `export` inside a shell call sets the environment of that shell. The hook runs as its own process and sees neither, so the write stays denied with nothing to explain the difference. [`gh-write-guard.py`][write-guard] asserts the inline-prefix case in its own self-test, so this is settled behavior rather than a quirk to work around. - -**Confirm the grant loaded before relying on it**, since inferring it from a write that no longer denies means learning the answer by making the write. In a restarted session in that checkout, read the variable the hook reads: - -```shell -printenv GH_WRITE_GUARD_ALLOW -``` - -Run it bare, with no `VAR=value` prefix of its own, which would report a value the hook never sees. An empty result means the grant did not load, and the fix is the file location or the restart rather than the token. Feeding the hook a synthetic payload is not a usable probe from inside a session, because the payload text carries the very write shape the guard matches and the guard denies the probe command itself. - -Withdraw a grant by deleting the `env` entry and restarting. Nothing expires it, so a grant left in place stays live for every later session in that checkout, which is the reason to remove it once the work that needed it is done. - -## Fleet Skills Install - -The fleet's agent skills are hand-authored in the hub at `.agents/skills/` and installed per user by [`scripts/skills_install.py`][skills-install]: an overlay copy into `~/.agents/skills/` for Codex and opencode, and a user-scope Claude Code plugin install where the `claude` CLI is present. Every run stamps the hub commit it installed from into `~/.agents/skills-install-stamp.json`, and `--report` reads that stamp against the checkout and exits non-zero where the machine is behind it. - -Install from a hub checkout, once per machine: - -```shell -python3 scripts/skills_install.py # or the scripts/skills_install.sh / .ps1 wrapper -python3 scripts/skills_install.py --report # read-only: is this machine current? -``` - -A bootstrapped host does not run this by hand: the `--host` mode of [`host-setup/bootstrap.sh`][bootstrap] and [`bootstrap.ps1`][bootstrap-ps1] ends with the same installer, driven from the fetched tree by `install-skills.sh` or `install-skills.ps1`, and the `--skills` action runs that step on its own. - -The `claude` CLI is deliberately absent from the tool catalog in [`spec/host-tools.json`][host-tools]. A Codex-only machine is a complete machine, so the installer degrades where the CLI is missing, still landing the overlay half, saying so, and recording the partial install in the stamp, where cataloging the CLI would instead fail every host that never wanted it. - -**The 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, and a rule that keeps needing to be restated in a session is the loudest form of it, which is the symptom the `fleet-conformance-check` skill routes to this report. The maintainer runs the refresh by hand, and an automated one stays out of scope until the fleet has evidence the manual cadence fails. - -## Verify Host Setup - -```shell -python3 scripts/host_gate.py # presence and version floors, from spec/host-tools.json -python3 scripts/skills_install.py --report # the skills install stamp is current -git config --global --list | grep -E "user\.|signing|gpg\." -# One physical line, not backslash-joined: this file is CRLF (the repo's Markdown default), -# and a `\` continuation stops working the moment a stray `\r` lands after it. -d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( trap 'rm -rf "$d"' 0; email=$(git config --global --get user.email) && git init -q "$d" && git -C "$d" commit --allow-empty -q -m check && out=$(git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>') && echo "$out" && ae=$(git -C "$d" log -1 --format='%ae') && ce=$(git -C "$d" log -1 --format='%ce') && case "$out" in sig=G\ *|sig=U\ *) true ;; *) false ;; esac && case "$email" in *@users.noreply.github.com) true ;; *) false ;; esac && [ "$ae" = "$email" ] && [ "$ce" = "$email" ] ) -gh auth status -``` - -`sig` must read `G` (good signature) or `U` (good signature, unrecognized signer). For GPG, `U` is a valid signature from a key whose trust level is merely undefined, common right after generating a new key. For SSH, it's a valid signature from a key not found in the local `allowed_signers` file, which doesn't affect whether GitHub itself verifies the commit, only local `git verify-commit` output. Both the `author` and `committer` email must be an actual noreply address, and both must match `user.email` from the config line above, all enforced by the snippet itself. `ssh-add -L` (or a `gpg --list-secret-keys` equivalent) is not a substitute: it only proves an agent holds a key, and a host that signs straight from a key file with no agent running passes this scratch commit while failing that probe, per [GOVERNANCE.md "Git and Commit Rules"][governance-git-and-commit-rules]. If signing fails locally, the devcontainer will fail too, so fix here first. - -The gate replaced a line that ran `--version` on each tool and read only whether it answered. That form reported a host carrying the broken `gh` as fully set up, which is the failure it exists to stop. It exits non-zero on a missing required tool or one below its floor, and a below-floor finding prints the defect behind the floor rather than the number alone, names where to install from, and prints the command that installs or upgrades the tool on the current platform, so that failure carries its own fix. A missing tool prints the one-line fact, and [`host-setup/`][host-setup-dir] is its remedy. - -**This block is POSIX, and on native Windows two lines need translating.** Run the POSIX form from WSL2 or Git Bash per the shell note, or use the PowerShell form below. Git Bash inherits the Windows `PATH`, so `python3` reaches the same Store alias stub it does in PowerShell and reports a working interpreter as missing. - -```powershell -py -3 scripts/host_gate.py # presence and version floors, from spec/host-tools.json -py -3 scripts/skills_install.py --report # the skills install stamp is current -git config --global --list | Select-String "user\.|signing|gpg\." -$d = Join-Path $env:TEMP ([guid]::NewGuid()) -try { - $email = git config --global --get user.email - git init -q "$d" ` - && git -C "$d" commit --allow-empty -q -m check - $out = git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' - $out - $ae = git -C "$d" log -1 --format='%ae' - $ce = git -C "$d" log -1 --format='%ce' - if ($out -notmatch '^sig=[GU] ' -or $email -notmatch '@users\.noreply\.github\.com$' ` - -or $ae -ne $email -or $ce -ne $email) { - throw "signing/identity check failed: $out" - } -} finally { - if (Test-Path "$d") { Remove-Item -Recurse -Force "$d" } -} -gh auth status -``` - -Verified on Windows 11 Pro 10.0.26200 with PowerShell 7.6.4, where `py -3 scripts/host_gate.py` exits 0 over seven declared tools. It was supplied under [#483][issue-483], which had deferred it until somebody had executed it on a Windows host. Only two lines differ from the POSIX block: the interpreter, and the filter, because `grep` has no Windows peer and `Select-String` is the one that ships. - -**`py -3` rather than `python`, and the reason is not only the Store stub.** Both names reach the same interpreter on a correctly set-up host, so the stub rules out `python3` and chooses nothing between the other two. What chooses is that an activated virtual environment puts its own interpreter first, so `python` resolves to that environment's. That is right for running project code and wrong here, because this gate measures **the host's** interpreter against a floor, and run as `python` from an activated environment it grades the environment instead. `py` is the launcher and reaches a registered system interpreter whatever is active. The prescription is therefore narrow: `py -3` for this gate, and `python` for everything else. - -**What the host can do once this passes**, which is the point of the contract above: - -| Now possible | Because | -| --- | --- | -| Stand up a new repo through [`STANDUP.md`][standup] | step 0 verifies identity and signing, and its window closes at the first commit | -| Run the four linters locally, matching CI | `docker` runs each as the same pinned image CI uses | -| Run the repo's own gates and tests | Python 3 covers `scripts/` and `spec/` with no packages to install | -| Drive the PR and Copilot review loop | `gh` and an authenticated session | -| Let an agent work with the `gh` credentials live | the write-safety kit is installed | -| Have the fleet skills surface in every agent session | the skills install stamp is current per `skills_install.py --report` | - -A host that fails any row is not ready for the procedure that row names, and the fix belongs on the host rather than in a repo. - -## Next Steps - -- [Devcontainer setup][devcontainer]: open the repo in the per-language .NET or Python devcontainer. -- [SSH commit signing][ssh-signing]: per-OS setup details, verification, and troubleshooting. - -<!-- Repo --> - -[agent-safety]: ../host-setup/agent-safety/README.md -[audit]: ../AUDIT.md -[bootstrap]: ../host-setup/bootstrap.sh -[bootstrap-ps1]: ../host-setup/bootstrap.ps1 -[devcontainer]: ./devcontainer.md -[governance-git-and-commit-rules]: ../GOVERNANCE.md#git-and-commit-rules -[host-gate]: ../scripts/host_gate.py -[host-setup-dir]: ../host-setup/ -[host-setup-windows]: ../host-setup/windows/ -[host-tools]: ../spec/host-tools.json -[issue-483]: https://github.com/ptr727/ProjectTemplate/issues/483 -[operations]: ../OPERATIONS.md -[scripts-dir]: ../scripts/ -[skills-install]: ../scripts/skills_install.py -[spec-dir]: ../spec/ -[ssh-signing]: ./ssh-signing.md -[standup]: ../STANDUP.md -[write-guard]: ../host-setup/agent-safety/gh-write-guard.py - -<!-- External --> - -[cli-install-link]: https://github.com/cli/cli/blob/trunk/docs/install_linux.md -[cli-link]: https://cli.github.com/ -[docker-install-link]: https://docs.docker.com/engine/install/ -[git-restore-mtime-action-link]: https://github.com/chetan/git-restore-mtime-action -[git-tools-link]: https://github.com/MestreLion/git-tools -[keys-link]: https://github.com/settings/keys +# Host Setup + +Prerequisites for working with this repo locally, applied once per machine before opening the devcontainer or building outside one. + +Supported hosts: + +- **Linux** - both the devcontainer flow and the host-install flow. +- **macOS** - both the devcontainer flow and the host-install flow. +- **Windows** - the devcontainer flow requires **WSL2**, and native Windows (PowerShell + winget) is supported only for the host-install flow described in `README.md`. The bind-mounts in the `catalog/snippets/devcontainer/` definitions rely on POSIX paths and only work from Linux/macOS/WSL2. + +> **Shell assumptions in this doc**: every command snippet below assumes a **POSIX shell** (bash/zsh) and POSIX path conventions (`~/.ssh/...`, `mkdir -p`, `$(...)` command substitution), except where a block is marked `powershell`. Such a block is the **Windows-native** form of the step it sits in, meant to run in PowerShell rather than translated. On Windows, run the POSIX snippets from **WSL2** or **Git Bash**, since they will not work as-is in PowerShell or `cmd.exe`. The git config and `gh` commands are portable, and only the file and path manipulation differs by shell. + +## What a Host Must Provide + +This section is the **contract**: which tools a host needs and which repo procedure stops working without each one. It deliberately names no installer, because `winget`, `brew` and `apt` differ per platform while the requirement does not. Per-platform install commands are tracked separately, in [`host-setup/`][host-setup-dir], so this table stays true on every host. + +| Tool | Needed by | Present when | Floor | +| --- | --- | --- | --- | +| `git` | everything, and the identity and signing contract in [`STANDUP.md`][standup] step 0 | `git --version` | none | +| `gh` | the PR and review loop, `gh api` queries, `repo-config/configure.sh` | `gh --version` | **2.47.0**, measured | +| Python 3 | `scripts/` and `spec/` (standard library only, no packages to install) | `python3 --version`, or `py -3 --version` on native Windows | **3.13**, target | +| `jq` | the ruleset normalizer in `repo-config/configure.sh`, the ruleset diff in [`AUDIT.md`][audit] section 6, and payload regeneration | `jq --version` | **1.7**, target | +| `docker` | the four linters, which run as pinned images rather than local installs | `docker --version` | **29.6.2**, target | +| `uv` / `uvx` | coverage runs, and the Python toolchain (`ruff`, `pyright` or `mypy`) in a Python repo | `uv --version` | **0.12.2**, target | + +The **Floor** column exists because presence and sufficiency are different questions and the answer to the first was being read as the answer to the second. A tool below its floor still answers `--version`, so every other column reports it as fine while `scripts/host_gate.py` fails it. The kind is named beside the number, since a **measured** floor sits above a version known to break a documented procedure and gives a failing host a defect to point at, where a **target** floor names the version the repo's toolchain is configured for and does not. The next section carries the reasoning behind each one. + +Two consequences worth reading off the table rather than discovering later. **Python 3 needs no packages**, because every script here is standard library only, so a bare interpreter is enough. And **the linters need only `docker`**, not `node`, `dotnet` or a local `markdownlint`, since each runs as a pinned image, which is what keeps a local run and CI the same check. + +**The interpreter is not called `python3` everywhere.** On native Windows the installer registers `python`, `py` and `python3.13` but **not** `python3`, where that name instead resolves to a Microsoft Store alias stub that reports the interpreter as missing, so a correctly set-up host fails a `python3` check. Stock Debian is the mirror image, carrying `python3` and no bare `python`. Use `py -3` on native Windows and `python3` elsewhere, and prefer `python3` in any script that must run on both, since WSL2 shadows the Windows stub. + +A missing tool is a host gap, not a repo problem. Install it and re-run, rather than working around it in a repo. + +### Where a Tool Comes From, and How Old It May Be + +Presence is the weaker half of this contract. Both host defects this fleet has actually hit are **version** facts on a tool that is installed, answers `--version`, and looks healthy, so the table above cannot see either one. [`spec/host-tools.json`][host-tools] carries the floors as data and records the defect each one encodes, and [`scripts/host_gate.py`][host-gate] reads it. A floor is one of two kinds and names its own kind in the `why` it carries. A **measured** floor sits above a version known to break a documented procedure, which is what both `gh` and `git-restore-mtime` carry. A **target** floor names the version the repo's own toolchain is configured for, which is what `python3` carries at 3.13, where `pyproject.toml` sets ruff and mypy to that version, so a lower interpreter is unverified rather than known broken and the entry says exactly that. Everything else carries none, deliberately: a floor nobody can justify becomes a host failure nobody can act on. + +**`gh` must not come from the distribution's package on Linux.** This is the one place this document names a source, because here the source *is* the requirement rather than a convenience. The GitHub CLI maintainers state that the community-distributed `2.45.x` / `2.46.x` is **broken by deprecated GitHub APIs**, so install from the official apt repository at [cli.github.com][cli-install-link] and upgrade from there. Both `gh` limitations recorded in [`OPERATIONS.md`][operations] were observed on a host carrying a distribution `gh 2.46.0`, and both are the deprecation class that note describes. On **Windows** `winget` tracks upstream releases, and on macOS Homebrew does, so neither raises this hazard and neither needs a note of its own. + +**`git-restore-mtime` must not come from it either, where a repo uses it.** Debian and Ubuntu package **2022.12**, which shells out to `git whatchanged`. Current `git` refuses that without a hidden opt-in flag a caller cannot pass through, so the tool restores nothing, prints its ordinary statistics and **exits 0**. A deploy keyed on mtimes then ships a full copy and reports success. Take the upstream release from [git-tools][git-tools-link], or in CI the [action][git-restore-mtime-action-link] that vendors it. Note the direction of that interaction: a **newer** `git` is the trigger rather than the remedy, so a host old enough to still allow `whatchanged` hides the defect rather than avoiding it. No procedure in this repo needs the tool, so the gate declares it **optional** and skips it when absent. + +**`docker` must not come from the distribution's own package either, with one exception.** Debian and Ubuntu package `docker.io`, an older build that trails and conflicts with `docker-ce`, so [`host-setup/linux/install-tools.sh`][host-setup-dir] removes it and installs from Docker's own apt repository at [download.docker.com][docker-install-link] instead, the same shape it already uses for `gh` and `node`. The exception is a **WSL distribution**, where the only sanctioned source is Docker Desktop's own WSL integration (Settings, Resources, WSL integration, on the Windows side, reported read-only by [`setup-wsl.ps1`][host-setup-windows]) and a native install is refused outright, with no override: running `docker-ce` directly inside a WSL distribution risks a second engine beside Desktop's own. On native **Windows**, `winget` already tracks upstream Desktop releases, so neither hazard arises there. + +**`docker`'s floor is read from the engine, not from the CLI banner.** The `docker` on `PATH` inside a WSL distribution can be a separately packaged client that talks to Docker Desktop's engine over the integration socket, and the two then carry different versions: a host was recorded with a distribution client at `29.1.3` against an engine at `29.7.2`, so `docker --version` failed a floor the engine cleared comfortably. The gate therefore asks the daemon first, with `docker version --format '{{.Server.Version}}'`, and falls back to the `docker --version` banner only where that exits non-zero, which is what a stopped or unreachable daemon does. The **Present when** column above still names the banner, because presence is what it answers and a host with the daemon stopped still has `docker` installed. + +**The rest of the table takes the distribution's package, and one more does not.** `git` and the Python interpreter come from the distribution, because each keeps up well enough that a second source buys nothing and costs a repository to trust. `jq` is the same on a current Debian or Ubuntu, which carries a version at or above the floor, and the upstream release binary is the answer only where it does not. `uv` is published by its authors as a release archive and packaged by neither distribution, so upstream is the only source there is. Where a repository needs `node`, the distribution's package trails upstream by whole release lines, so it comes from the NodeSource repository on the line upstream currently carries as long term support. Where a repository needs `dotnet`, the distribution's feed is preferred where it carries an SDK and Microsoft's feed is the fallback, because mixing the two is what breaks a host rather than either one alone, and Microsoft's carries `amd64` only. + +Neither `node` nor `dotnet` is in the table above, deliberately: they serve the repositories that need them rather than the fleet contract, and a repository needing one declares it in a `host-tools.json` of its own, which [`scripts/host_gate.py`][host-gate] merges over this one. The merge tightens only, so a repository may raise a floor or add one and may not lower or remove one. + +**A host being stood up needs no Python.** The tooling under [`host-setup/`][host-setup-dir] is shell and PowerShell, deliberately, because requiring an interpreter to upgrade a package or install a tool would make the first step of standing a host up depend on the thing that step exists to provide. The Python floor above is a development requirement, meaning [`scripts/`][scripts-dir] and [`spec/`][spec-dir], and a host that only runs services never has to meet it. `bootstrap.sh` needs `curl` and `tar`, both of which a base install carries or can install without a network tool of its own. `bootstrap.ps1` needs only `tar.exe`, which has shipped with Windows since 1803, and installs its one further dependency, PowerShell 7, itself through `winget`. The one exception is the skills step at the end of a stand-up, which drives the Python installer in [`scripts/`][scripts-dir], and it runs last for exactly that reason: `install-tools` has provided the interpreter by then, and run alone on a host without one it stops and names the tools step as its prerequisite. + +**Standing a host up.** [`host-setup/`][host-setup-dir] carries the tooling that makes a host satisfy this contract, and its README is the usage. A host with nothing runs [`host-setup/bootstrap.sh`][bootstrap], which fetches this repository and runs that tooling from the fetched tree. A native Windows host with nothing runs [`host-setup/bootstrap.ps1`][bootstrap-ps1] the same way, which finds or installs PowerShell 7 before it fetches anything, since every script under [`host-setup/windows/`][host-setup-windows] requires it. Neither is called by [`scripts/host_gate.py`][host-gate] and neither calls it: the gate measures a host against the floors above, and the tooling is a remedy a person chooses when the gate reports a gap. + +A repository that needs more than the fleet does adds its own `host-tools.json` at its root, which the gate layers over the hub's. It may add a tool nobody else uses, raise a floor, or turn an optional tool required. It may **not** lower a floor or turn a required tool optional, since those edits retire a fleet check from inside the repository it protects, and the gate reports a rejected relaxation rather than dropping it. + +## Git Identity + +Configure your name and email, used for commit authorship. **The email is the committing account's GitHub `noreply` address, never a private, personal, or invented one**, per [GOVERNANCE.md "Git and Commit Rules"][governance-git-and-commit-rules], which owns the rule and states the fleet's value. A private address trips GitHub's email-privacy push protection (GH007), and an invented one pollutes history. + +```shell +git config --global user.name "Your Name" +git config --global user.email "<id>+<username>@users.noreply.github.com" +``` + +Set this **globally**, once per machine. Repositories inherit it, so a repo-local `user.email` is redundant where the global is right and a wrong identity where it is not. An agent standing up a repo verifies this configuration rather than setting it ([`STANDUP.md`][standup] step 0). + +## SSH Key + +Generate an Ed25519 SSH key for both authentication and commit signing. One key serves both roles. + +```shell +ssh-keygen -t ed25519 -C "you@example.com" -f ~/.ssh/id_ed25519 +``` + +Add the public key (`~/.ssh/id_ed25519.pub`) to GitHub twice: + +1. **Authentication key** - [GitHub -> Settings -> SSH and GPG keys -> New SSH key][keys-link], key type **Authentication Key**. +2. **Signing key** - same page, but **Signing Key** type. GitHub treats these independently even though it's the same public key. + +Test the auth key: + +```shell +ssh -T git@github.com +``` + +## SSH Config + +Tell SSH which key to use for `github.com`. Pick the snippet for your platform. + +### Linux / WSL2 + +```sshconfig +# ~/.ssh/config +Host github.com + HostName github.com + User git + IdentityFile ~/.ssh/id_ed25519 + IdentitiesOnly yes +``` + +Make sure ssh-agent is running and the key is loaded. On systemd-based distros: + +```shell +systemctl --user enable --now ssh-agent.socket +ssh-add ~/.ssh/id_ed25519 +``` + +For non-systemd shells, add to `~/.bashrc` or `~/.zshrc`. The check probes the agent for at least one loaded key, because `[ -z "$SSH_AUTH_SOCK" ]` alone would miss the case where `SSH_AUTH_SOCK` is set but points at a stale socket or a keyless agent: + +```shell +if [ -z "$SSH_AUTH_SOCK" ] || ! ssh-add -l >/dev/null 2>&1; then + eval "$(ssh-agent -s)" >/dev/null + ssh-add ~/.ssh/id_ed25519 2>/dev/null +fi +``` + +### macOS + +```sshconfig +# ~/.ssh/config +Host github.com + HostName github.com + User git + IdentityFile ~/.ssh/id_ed25519 + IdentitiesOnly yes + UseKeychain yes + AddKeysToAgent yes +``` + +Load the key into the macOS Keychain so it's available without re-entering the passphrase: + +```shell +ssh-add --apple-use-keychain ~/.ssh/id_ed25519 +``` + +## Allowed Signers File + +Required for SSH signature verification by `git verify-commit` and similar tools. Without it git can sign commits but not verify them locally. + +```shell +mkdir -p ~/.config/git +echo "$(git config --global user.email) namespaces=\"git\" $(cat ~/.ssh/id_ed25519.pub)" >> ~/.config/git/allowed_signers +git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers +``` + +## Configure Git for SSH Signing + +```shell +git config --global gpg.format ssh +git config --global user.signingkey ~/.ssh/id_ed25519.pub +git config --global commit.gpgsign true +git config --global tag.gpgsign true +``` + +See [SSH commit signing][ssh-signing] for verification steps and per-OS troubleshooting. + +## GitHub CLI + +Install [`gh`][cli-link] and authenticate. + +```shell +gh auth login --hostname github.com --git-protocol ssh +``` + +Choose the SSH key generated above when prompted. + +## Agent Write-Safety Kit + +Required on any host where an agent runs with the `gh` credentials logged in, and its own README calls it the first thing to deploy on a new system. Install it from this repo, since the installer is idempotent and safe to re-run to update: + +```shell +host-setup/agent-safety/install.sh # Linux, WSL, macOS +``` + +```powershell +.\host-setup\agent-safety\install.ps1 # Windows, and the .\ prefix is required +``` + +Both wrap one `install.py`, so every platform runs the same tested path. Restart Claude Code sessions on the machine afterward so the hook and the `CLAUDE.md` block load. Details, verification, and scope limits are in [`host-setup/agent-safety/README.md`][agent-safety]. + +This is a **host** control, not a repo one. The carried `GOVERNANCE.md` rules reach fleet repos only, while the hook and the `CLAUDE.md` block cover every session on the machine, including ad-hoc work in no project at all, which is where the incident behind the kit happened. + +### Granting a Write the Guard Denies + +The guard denies a `gh` write whose explicit target sits under an owner other than the checkout's `origin` owner, and the denial names `GH_WRITE_GUARD_ALLOW` as the way past it. That grant is the maintainer's to make, and making it is a deliberate act taken outside the session rather than something an agent does for itself once blocked. + +**The case that raises it is usually a fork.** `origin` is your own fork under your own owner, and `upstream` is the project it was forked from under someone else's. Everything aimed at the fork is in scope and never denies, and only the half that leaves the owner stops: filing an issue on the upstream, opening a pull request against it, or commenting on one there. The grant therefore names the upstream alone, and the fork needs no grant at all. That asymmetry is what a reader hits first, since half the session's writes succeed and the other half do not. + +**The grant goes in the checkout's `.claude/settings.local.json`, as an `env` block:** + +```json +{ + "env": { + "GH_WRITE_GUARD_ALLOW": "upstream-owner/upstream-repo second-owner/other-repo third-owner/*" + } +} +``` + +**The value is one string holding every grant, never a JSON array**, since the hook reads an environment variable and an environment variable is a string. The three tokens above are three separate grants: two naming one repository each, and `third-owner/*` granting every repository under that owner. + +Tokens are separated by **any run of whitespace or commas**, so `a/b c/d`, `a/b,c/d`, and `a/b, c/d` all parse to the same two grants and the choice is cosmetic. A token carrying no `/` is ignored, so a malformed entry grants nothing rather than granting everything, and it also fails silently, which is why the confirmation step below is worth running. Grant the narrowest thing that unblocks the work, since a repository grant does not extend to that owner's other repositories and that containment is the property worth keeping. + +**The grant is per checkout, not per host.** `.claude/settings.local.json` lives in the working tree and is git-ignored, so it applies to sessions started in that checkout and does not follow the agent into another repository's sessions. That is the intended scope: a grant made to file one upstream issue from one fork does not quietly become a standing permission everywhere. + +**Restart the session afterward.** The hook reads the value from the environment the session was launched with, which is what makes the channel one an agent cannot use on itself, and it is equally why a grant added to a live session does nothing until that session restarts. + +**Two forms look right and leave the write denied.** An inline `GH_WRITE_GUARD_ALLOW=owner/repo gh ...` prefix sets the environment of the `gh` process, and an `export` inside a shell call sets the environment of that shell. The hook runs as its own process and sees neither, so the write stays denied with nothing to explain the difference. [`gh-write-guard.py`][write-guard] asserts the inline-prefix case in its own self-test, so this is settled behavior rather than a quirk to work around. + +**Confirm the grant loaded before relying on it**, since inferring it from a write that no longer denies means learning the answer by making the write. In a restarted session in that checkout, read the variable the hook reads: + +```shell +printenv GH_WRITE_GUARD_ALLOW +``` + +Run it bare, with no `VAR=value` prefix of its own, which would report a value the hook never sees. An empty result means the grant did not load, and the fix is the file location or the restart rather than the token. Feeding the hook a synthetic payload is not a usable probe from inside a session, because the payload text carries the very write shape the guard matches and the guard denies the probe command itself. + +Withdraw a grant by deleting the `env` entry and restarting. Nothing expires it, so a grant left in place stays live for every later session in that checkout, which is the reason to remove it once the work that needed it is done. + +## Fleet Skills Install + +The fleet's agent skills are hand-authored in the hub at `.agents/skills/` and installed per user by [`scripts/skills_install.py`][skills-install]: an overlay copy into `~/.agents/skills/` for Codex and opencode, and a user-scope Claude Code plugin install where the `claude` CLI is present. Every run stamps the hub commit it installed from into `~/.agents/skills-install-stamp.json`, and `--report` reads that stamp against the checkout and exits non-zero where the machine is behind it. + +Install from a hub checkout, once per machine: + +```shell +python3 scripts/skills_install.py # or the scripts/skills_install.sh / .ps1 wrapper +python3 scripts/skills_install.py --report # read-only: is this machine current? +``` + +A bootstrapped host does not run this by hand: the `--host` mode of [`host-setup/bootstrap.sh`][bootstrap] and [`bootstrap.ps1`][bootstrap-ps1] ends with the same installer, driven from the fetched tree by `install-skills.sh` or `install-skills.ps1`, and the `--skills` action runs that step on its own. + +The `claude` CLI is deliberately absent from the tool catalog in [`spec/host-tools.json`][host-tools]. A Codex-only machine is a complete machine, so the installer degrades where the CLI is missing, still landing the overlay half, saying so, and recording the partial install in the stamp, where cataloging the CLI would instead fail every host that never wanted it. + +**The 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, and a rule that keeps needing to be restated in a session is the loudest form of it, which is the symptom the `fleet-conformance-check` skill routes to this report. The maintainer runs the refresh by hand, and an automated one stays out of scope until the fleet has evidence the manual cadence fails. + +## Verify Host Setup + +```shell +python3 scripts/host_gate.py # presence and version floors, from spec/host-tools.json +python3 scripts/skills_install.py --report # the skills install stamp is current +git config --global --list | grep -E "user\.|signing|gpg\." +# One physical line, not backslash-joined, so the whole probe copy-pastes cleanly into a shell. +d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( trap 'rm -rf "$d"' 0; email=$(git config --global --get user.email) && git init -q "$d" && git -C "$d" commit --allow-empty -q -m check && out=$(git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>') && echo "$out" && ae=$(git -C "$d" log -1 --format='%ae') && ce=$(git -C "$d" log -1 --format='%ce') && case "$out" in sig=G\ *|sig=U\ *) true ;; *) false ;; esac && case "$email" in *@users.noreply.github.com) true ;; *) false ;; esac && [ "$ae" = "$email" ] && [ "$ce" = "$email" ] ) +gh auth status +``` + +`sig` must read `G` (good signature) or `U` (good signature, unrecognized signer). For GPG, `U` is a valid signature from a key whose trust level is merely undefined, common right after generating a new key. For SSH, it's a valid signature from a key not found in the local `allowed_signers` file, which doesn't affect whether GitHub itself verifies the commit, only local `git verify-commit` output. Both the `author` and `committer` email must be an actual noreply address, and both must match `user.email` from the config line above, all enforced by the snippet itself. `ssh-add -L` (or a `gpg --list-secret-keys` equivalent) is not a substitute: it only proves an agent holds a key, and a host that signs straight from a key file with no agent running passes this scratch commit while failing that probe, per [GOVERNANCE.md "Git and Commit Rules"][governance-git-and-commit-rules]. If signing fails locally, the devcontainer will fail too, so fix here first. + +The gate replaced a line that ran `--version` on each tool and read only whether it answered. That form reported a host carrying the broken `gh` as fully set up, which is the failure it exists to stop. It exits non-zero on a missing required tool or one below its floor, and a below-floor finding prints the defect behind the floor rather than the number alone, names where to install from, and prints the command that installs or upgrades the tool on the current platform, so that failure carries its own fix. A missing tool prints the one-line fact, and [`host-setup/`][host-setup-dir] is its remedy. + +**This block is POSIX, and on native Windows two lines need translating.** Run the POSIX form from WSL2 or Git Bash per the shell note, or use the PowerShell form below. Git Bash inherits the Windows `PATH`, so `python3` reaches the same Store alias stub it does in PowerShell and reports a working interpreter as missing. + +```powershell +py -3 scripts/host_gate.py # presence and version floors, from spec/host-tools.json +py -3 scripts/skills_install.py --report # the skills install stamp is current +git config --global --list | Select-String "user\.|signing|gpg\." +$d = Join-Path $env:TEMP ([guid]::NewGuid()) +try { + $email = git config --global --get user.email + git init -q "$d" ` + && git -C "$d" commit --allow-empty -q -m check + $out = git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' + $out + $ae = git -C "$d" log -1 --format='%ae' + $ce = git -C "$d" log -1 --format='%ce' + if ($out -notmatch '^sig=[GU] ' -or $email -notmatch '@users\.noreply\.github\.com$' ` + -or $ae -ne $email -or $ce -ne $email) { + throw "signing/identity check failed: $out" + } +} finally { + if (Test-Path "$d") { Remove-Item -Recurse -Force "$d" } +} +gh auth status +``` + +Verified on Windows 11 Pro 10.0.26200 with PowerShell 7.6.4, where `py -3 scripts/host_gate.py` exits 0 over seven declared tools. It was supplied under [#483][issue-483], which had deferred it until somebody had executed it on a Windows host. Only two lines differ from the POSIX block: the interpreter, and the filter, because `grep` has no Windows peer and `Select-String` is the one that ships. + +**`py -3` rather than `python`, and the reason is not only the Store stub.** Both names reach the same interpreter on a correctly set-up host, so the stub rules out `python3` and chooses nothing between the other two. What chooses is that an activated virtual environment puts its own interpreter first, so `python` resolves to that environment's. That is right for running project code and wrong here, because this gate measures **the host's** interpreter against a floor, and run as `python` from an activated environment it grades the environment instead. `py` is the launcher and reaches a registered system interpreter whatever is active. The prescription is therefore narrow: `py -3` for this gate, and `python` for everything else. + +**What the host can do once this passes**, which is the point of the contract above: + +| Now possible | Because | +| --- | --- | +| Stand up a new repo through [`STANDUP.md`][standup] | step 0 verifies identity and signing, and its window closes at the first commit | +| Run the four linters locally, matching CI | `docker` runs each as the same pinned image CI uses | +| Run the repo's own gates and tests | Python 3 covers `scripts/` and `spec/` with no packages to install | +| Drive the PR and Copilot review loop | `gh` and an authenticated session | +| Let an agent work with the `gh` credentials live | the write-safety kit is installed | +| Have the fleet skills surface in every agent session | the skills install stamp is current per `skills_install.py --report` | + +A host that fails any row is not ready for the procedure that row names, and the fix belongs on the host rather than in a repo. + +## Next Steps + +- [Devcontainer setup][devcontainer]: open the repo in the per-language .NET or Python devcontainer. +- [SSH commit signing][ssh-signing]: per-OS setup details, verification, and troubleshooting. + +<!-- Repo --> + +[agent-safety]: ../host-setup/agent-safety/README.md +[audit]: ../AUDIT.md +[bootstrap]: ../host-setup/bootstrap.sh +[bootstrap-ps1]: ../host-setup/bootstrap.ps1 +[devcontainer]: ./devcontainer.md +[governance-git-and-commit-rules]: ../GOVERNANCE.md#git-and-commit-rules +[host-gate]: ../scripts/host_gate.py +[host-setup-dir]: ../host-setup/ +[host-setup-windows]: ../host-setup/windows/ +[host-tools]: ../spec/host-tools.json +[issue-483]: https://github.com/ptr727/ProjectTemplate/issues/483 +[operations]: ../OPERATIONS.md +[scripts-dir]: ../scripts/ +[skills-install]: ../scripts/skills_install.py +[spec-dir]: ../spec/ +[ssh-signing]: ./ssh-signing.md +[standup]: ../STANDUP.md +[write-guard]: ../host-setup/agent-safety/gh-write-guard.py + +<!-- External --> + +[cli-install-link]: https://github.com/cli/cli/blob/trunk/docs/install_linux.md +[cli-link]: https://cli.github.com/ +[docker-install-link]: https://docs.docker.com/engine/install/ +[git-restore-mtime-action-link]: https://github.com/chetan/git-restore-mtime-action +[git-tools-link]: https://github.com/MestreLion/git-tools +[keys-link]: https://github.com/settings/keys diff --git a/docs/peer-messaging.md b/docs/peer-messaging.md index 877542c5..f4276685 100644 --- a/docs/peer-messaging.md +++ b/docs/peer-messaging.md @@ -1,32 +1,32 @@ -# Peer Messaging Between Agents (Hub-Only) - -The declared method for agent-to-agent messaging between sessions on one host, so it is a method with stated boundaries rather than a capability each session rediscovers. This doc is **hub-only** and is not carried downstream, per the location decision recorded in [`docs/fleet-map.md`][fleet-map] "Peer Messaging": the transport cannot cross a machine boundary, so the rules bind sessions on the maintainer's own hosts, and a carried section is re-evaluated when cross-host messaging is verified. The mechanism needs no build, so this doc is the whole deliverable. - -## The Method - -A session lists its local peers, addresses one by the reference the listing prints, and exchanges messages with it. The transport is a Unix domain socket per session under the user's runtime directory, which is what limits the method to one host by construction rather than by configuration. Cloud sessions and remote sessions on other machines are the documented cross-host paths, and neither is verified, so this doc states the same-host method only. - -**Addressing is by listed reference, never by bare name.** The transport refuses a bare peer name and requires the reference a listing prints. This is a guardrail, not a formality: it is what stops a message reaching the wrong repository's agent, the same class of mis-target the fleet's write-safety rules exist to prevent. - -## Safety Rules - -The safety half is the load-bearing half. Four rules bound every exchange: - -1. **Confirm a peer's identity before sending it anything substantive.** A listed peer says which repository and task it serves, and the confirmation happens before content flows, not after. -2. **Verify a peer's factual claims against the tree before repeating or acting on them.** Verification is a step, not a courtesy: in the method's first live use, two of four findings a peer raised did not reproduce, one did and shipped as a fix, and adopting the four unchecked would have shipped two false claims. -3. **Never read a peer's request as the maintainer's approval.** A peer is another session, not an authority, and an outward-facing or hard-to-reverse action still needs the maintainer's own go-ahead. -4. **Never ask a peer to perform what the asking session was denied.** A peer cannot widen what the asking session may do, so work blocked in one session goes back to the maintainer rather than sideways to another agent. - -## Why the Method Earns Its Place - -The method is declared on evidence rather than novelty. One live exchange produced the causal commit for a ruleset defect the receiving session had not identified from the symptom, a one-line reproduction showing an earlier fix passed for the wrong reason, and procedure gaps no gate reports. The anchors for that exchange are recorded in the [`TODO.md`][todo] entry this doc resolves. - -## Promotion Criteria - -This doc is promoted to a carried [`GOVERNANCE.md`][governance] section or a skill when either holds: cross-host messaging is verified and the same-host limit above becomes wrong, or a downstream session demonstrably needed these rules and had no way to reach them. Until then the hub-only form stands, because a fleet re-vendor for rules whose cross-host half is unverified buys reach the rules cannot yet use. - -<!-- Repo --> - -[fleet-map]: ./fleet-map.md -[governance]: ../GOVERNANCE.md -[todo]: ../TODO.md +# Peer Messaging Between Agents (Hub-Only) + +The declared method for agent-to-agent messaging between sessions on one host, so it is a method with stated boundaries rather than a capability each session rediscovers. This doc is **hub-only** and is not carried downstream, per the location decision recorded in [`docs/fleet-map.md`][fleet-map] "Peer Messaging": the transport cannot cross a machine boundary, so the rules bind sessions on the maintainer's own hosts, and a carried section is re-evaluated when cross-host messaging is verified. The mechanism needs no build, so this doc is the whole deliverable. + +## The Method + +A session lists its local peers, addresses one by the reference the listing prints, and exchanges messages with it. The transport is a Unix domain socket per session under the user's runtime directory, which is what limits the method to one host by construction rather than by configuration. Cloud sessions and remote sessions on other machines are the documented cross-host paths, and neither is verified, so this doc states the same-host method only. + +**Addressing is by listed reference, never by bare name.** The transport refuses a bare peer name and requires the reference a listing prints. This is a guardrail, not a formality: it is what stops a message reaching the wrong repository's agent, the same class of mis-target the fleet's write-safety rules exist to prevent. + +## Safety Rules + +The safety half is the load-bearing half. Four rules bound every exchange: + +1. **Confirm a peer's identity before sending it anything substantive.** A listed peer says which repository and task it serves, and the confirmation happens before content flows, not after. +2. **Verify a peer's factual claims against the tree before repeating or acting on them.** Verification is a step, not a courtesy: in the method's first live use, two of four findings a peer raised did not reproduce, one did and shipped as a fix, and adopting the four unchecked would have shipped two false claims. +3. **Never read a peer's request as the maintainer's approval.** A peer is another session, not an authority, and an outward-facing or hard-to-reverse action still needs the maintainer's own go-ahead. +4. **Never ask a peer to perform what the asking session was denied.** A peer cannot widen what the asking session may do, so work blocked in one session goes back to the maintainer rather than sideways to another agent. + +## Why the Method Earns Its Place + +The method is declared on evidence rather than novelty. One live exchange produced the causal commit for a ruleset defect the receiving session had not identified from the symptom, a one-line reproduction showing an earlier fix passed for the wrong reason, and procedure gaps no gate reports. The anchors for that exchange are recorded in the [`TODO.md`][todo] entry this doc resolves. + +## Promotion Criteria + +This doc is promoted to a carried [`GOVERNANCE.md`][governance] section or a skill when either holds: cross-host messaging is verified and the same-host limit above becomes wrong, or a downstream session demonstrably needed these rules and had no way to reach them. Until then the hub-only form stands, because a fleet re-vendor for rules whose cross-host half is unverified buys reach the rules cannot yet use. + +<!-- Repo --> + +[fleet-map]: ./fleet-map.md +[governance]: ../GOVERNANCE.md +[todo]: ../TODO.md diff --git a/docs/repo-config-carry.md b/docs/repo-config-carry.md index 6f743807..d5065baa 100644 --- a/docs/repo-config-carry.md +++ b/docs/repo-config-carry.md @@ -1,61 +1,61 @@ -# repo-config: Carry, Apply, and Regenerate (Hub-Only) - -The **process** for carrying the `repo-config/` baseline to a fleet repo, applying it, and regenerating the canonical payloads. This doc is **hub-only** and is not carried downstream (it describes what the hub does *to* a repo, not a fact about any one repo). The carried [`repo-config/README.md`][repo-config-readme] states only the current facts about a repo's own config. This carry/apply/regen procedure lives here so it never ships into a downstream copy. - -## Downstream Carry - -Every fleet repo carries the `repo-config/` directory. The hub keeps the canonical copy. Rules for the carried copy: - -- **The payloads carry and the script does not.** A `release` repo carries `develop.json`, an `operational` repo carries `operational/develop.json` instead, and `main.json` and `settings.json` are shared by both models. `configure.sh` stays in the hub and is run from a hub checkout against the repo named on the command line, per [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling], because it holds nothing per-repo and a copy of it is only current until the next fix. A repo still holding a copy has it deleted as it is next visited. Note the split this leaves: an apply or check run from the hub reads the hub's payloads rather than the repo's own, which is the single source the model is for, and the repo's carried payloads remain what its own `AUDIT.md` diffs the live rulesets against. -- **Carried files name no fleet repo as an illustrative example.** A carried file adds no template-repo reference and names no sibling fleet repo as an example (any fleet repo may be private, so such a link 404s in a public carrier, and it couples the repos). A contextually relevant link a reader of *this* repo's content needs is fine. See [GOVERNANCE.md "Documentation Style Conventions"][governance-documentation-style]. To point at a current good example, name it in the onboarding/conformance issue or the hub-only [`reports/conformance-matrix.md`][conformance-matrix]. -- **Adapted self-audit carry.** A downstream repo carries **locally adapted** `AUDIT.md` and `spec/secrets.json`, scoped to self-auditing its own rulesets, settings, and secrets against the committed `repo-config/` baseline, the standard shape, so the carried tooling is self-contained. The hub's fleet-wide audit remains authoritative. The adapted `AUDIT.md` is a settings diff, a normalized ruleset diff against the carried payloads (an operational carry swaps in `operational/develop.json`), and a names-only secrets check, all targeting the current repo. Adapt this shape, don't invent. A current well-formed example is named in the onboarding/conformance issue. -- **Adapted `spec/secrets.json` shape.** The repo-scoped adaptation carries `baseline` (the App pair, which every fleet repo needs for the merge-bot) plus a `mechanisms` entry for each publish mechanism the repo actually uses, and the `targetMechanisms` routing entries for those mechanisms. **A source-only repo whose publish targets all map to a null mechanism (nothing to route) carries just `baseline` (plus a `note`)**, omitting `targetMechanisms` and `mechanisms` entirely, because a lone `targetMechanisms` map with no `mechanisms` reads as a schema bug (the audit enumerates `baseline` + `mechanisms`, never `targetMechanisms`, so an all-null routing map is dead weight). A `release` repo that uses a real mechanism (e.g. `nuget-oidc`, `docker-hub`, `codecov`) carries that `mechanisms` entry **and** its `targetMechanisms`/`typeMechanisms` routing, which the audit then picks up. - -## Applying the Config - -**Configure by importing the JSON payloads, never by hand-building the rules** (hand reconstruction has gone wrong on past setups). The result must be **exactly two rulesets named `develop` and `main`**, and the names are load-bearing (`AGENTS.md` and the workflows reference them). Only the `develop` *content* varies by model. - -First remove all legacy classic branch-protection rules and any stray rulesets, then run `configure.sh apply` from a hub checkout, naming the target repo and its model (the script applies `settings.json` and the Dependabot security features alongside the rulesets). Name the model rather than leaving it to the lookup. The script reads the registry beside it and resolves a repo it does not find through `defaults.workflowModel` to `release`, so an unregistered operational repo silently takes the release `develop` ruleset. The carried-payload inference the script also carries is for a run with no registry to consult, which a run from here never is: - -```sh -repo-config/configure.sh apply owner/repo release|operational -``` - -Then validate the result with `repo-config/configure.sh check owner/repo release|operational`, run from the same checkout, which asserts every applied ruleset, setting, and security feature and exits non-zero on drift (the ruleset and settings checks are driven by the committed payloads, so they stay repo-agnostic). Or import each ruleset by hand with `gh api -X POST repos/<owner>/<repo>/rulesets --input repo-config/<name>.json` (operational repos use `operational/develop.json` for `develop`). `gh ruleset` is read-only, so creation goes through `gh api`. The required check binds by name and only turns green after the repo's PR workflow runs once. To edit a live ruleset, GET it, change the field, and PUT the whole writable subset back (a partial PUT `422`s). - -## Regenerating the Payloads - -To change the canonical rulesets, edit the live rulesets (fleet-wide changes happen at the hub), then regenerate the committed files from the current repo: - -```sh -repo="$(gh repo view --json nameWithOwner --jq '.nameWithOwner')" -# Paginate so a name match on a later page is never missed - the same trap configure.sh guards against. -# --paginate with --jq '.[]' emits one JSON object per ruleset across all pages; jq -s re-assembles them -# into the single array the selections below expect. -rulesets=$(gh api --paginate "repos/$repo/rulesets" --jq '.[]' | jq -s '.') -for name in develop main; do - out="repo-config/$name.json" - # An operational carry keeps its develop payload at operational/develop.json (develop.json is absent). - [ "$name" = "develop" ] && [ ! -f "$out" ] && out="repo-config/operational/develop.json" - # Exactly one ruleset per name: zero or duplicates is declared drift - fail loudly, never regen from a guess. - count=$(jq --arg n "$name" '[.[] | select(.name==$n)] | length' <<<"$rulesets") - [ "$count" -eq 1 ] || { echo "expected exactly 1 ruleset named $name, found $count (drift)" >&2; exit 1; } - id=$(jq --arg n "$name" '.[] | select(.name==$n) | .id' <<<"$rulesets") - # bypass_actors is left out, since a payload that declared one would assert this repo's bypass list against every repo diffed on it. - gh api "repos/$repo/rulesets/$id" \ - --jq '{name, target, enforcement, conditions, rules}' \ - | jq -S --indent 4 '.' > "$out" -done -``` - -## Brownfield Migration (Maintainer Only) - -`Require signed commits` rejects any pre-existing unsigned commit, so the first `develop -> main` release on a repo with unsigned history is blocked. Re-signing that history is a non-fast-forward that the `Block force pushes` rule rejects, **and the admin bypass does not cover `git push --force`**. Completing it requires temporarily disabling the ruleset and a maintainer force-push. This is a one-time, maintainer-performed migration that deliberately uses the force-push [GOVERNANCE.md "Git and Commit Rules"][governance-git-and-commit-rules] forbids agents from running. **An agent must never execute it, and must surface it to the maintainer instead.** Greenfield repos where signing is live before the first commit never hit this. When the rewrite touches commits committed under a bot or web-flow identity (`dependabot[bot]`, `github-actions[bot]`), set each commit's committer to the signing identity before re-signing so the committer GitHub verifies matches your key, rather than leaving your key over another identity's commit (see the history-rewrite rule in [GOVERNANCE.md "Git and Commit Rules"][governance-git-and-commit-rules]). - -<!-- Repo --> - -[conformance-matrix]: ../reports/conformance-matrix.md -[governance-documentation-style]: ../GOVERNANCE.md#documentation-style-conventions -[governance-git-and-commit-rules]: ../GOVERNANCE.md#git-and-commit-rules -[governance-hub-hosted-tooling]: ../GOVERNANCE.md#hub-hosted-tooling -[repo-config-readme]: ../repo-config/README.md +# repo-config: Carry, Apply, and Regenerate (Hub-Only) + +The **process** for carrying the `repo-config/` baseline to a fleet repo, applying it, and regenerating the canonical payloads. This doc is **hub-only** and is not carried downstream (it describes what the hub does *to* a repo, not a fact about any one repo). The carried [`repo-config/README.md`][repo-config-readme] states only the current facts about a repo's own config. This carry/apply/regen procedure lives here so it never ships into a downstream copy. + +## Downstream Carry + +Every fleet repo carries the `repo-config/` directory. The hub keeps the canonical copy. Rules for the carried copy: + +- **The payloads carry and the script does not.** A `release` repo carries `develop.json`, an `operational` repo carries `operational/develop.json` instead, and `main.json` and `settings.json` are shared by both models. `configure.sh` stays in the hub and is run from a hub checkout against the repo named on the command line, per [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling], because it holds nothing per-repo and a copy of it is only current until the next fix. A repo still holding a copy has it deleted as it is next visited. Note the split this leaves: an apply or check run from the hub reads the hub's payloads rather than the repo's own, which is the single source the model is for, and the repo's carried payloads remain what its own `AUDIT.md` diffs the live rulesets against. +- **Carried files name no fleet repo as an illustrative example.** A carried file adds no template-repo reference and names no sibling fleet repo as an example (any fleet repo may be private, so such a link 404s in a public carrier, and it couples the repos). A contextually relevant link a reader of *this* repo's content needs is fine. See [GOVERNANCE.md "Documentation Style Conventions"][governance-documentation-style]. To point at a current good example, name it in the onboarding/conformance issue or the hub-only [`reports/conformance-matrix.md`][conformance-matrix]. +- **Adapted self-audit carry.** A downstream repo carries **locally adapted** `AUDIT.md` and `spec/secrets.json`, scoped to self-auditing its own rulesets, settings, and secrets against the committed `repo-config/` baseline, the standard shape, so the carried tooling is self-contained. The hub's fleet-wide audit remains authoritative. The adapted `AUDIT.md` is a settings diff, a normalized ruleset diff against the carried payloads (an operational carry swaps in `operational/develop.json`), and a names-only secrets check, all targeting the current repo. Adapt this shape, don't invent. A current well-formed example is named in the onboarding/conformance issue. +- **Adapted `spec/secrets.json` shape.** The repo-scoped adaptation carries `baseline` (the App pair, which every fleet repo needs for the merge-bot) plus a `mechanisms` entry for each publish mechanism the repo actually uses, and the `targetMechanisms` routing entries for those mechanisms. **A source-only repo whose publish targets all map to a null mechanism (nothing to route) carries just `baseline` (plus a `note`)**, omitting `targetMechanisms` and `mechanisms` entirely, because a lone `targetMechanisms` map with no `mechanisms` reads as a schema bug (the audit enumerates `baseline` + `mechanisms`, never `targetMechanisms`, so an all-null routing map is dead weight). A `release` repo that uses a real mechanism (e.g. `nuget-oidc`, `docker-hub`, `codecov`) carries that `mechanisms` entry **and** its `targetMechanisms`/`typeMechanisms` routing, which the audit then picks up. + +## Applying the Config + +**Configure by importing the JSON payloads, never by hand-building the rules** (hand reconstruction has gone wrong on past setups). The result must be **exactly two rulesets named `develop` and `main`**, and the names are load-bearing (`AGENTS.md` and the workflows reference them). Only the `develop` *content* varies by model. + +First remove all legacy classic branch-protection rules and any stray rulesets, then run `configure.sh apply` from a hub checkout, naming the target repo and its model (the script applies `settings.json` and the Dependabot security features alongside the rulesets). Name the model rather than leaving it to the lookup. The script reads the registry beside it and resolves a repo it does not find through `defaults.workflowModel` to `release`, so an unregistered operational repo silently takes the release `develop` ruleset. The carried-payload inference the script also carries is for a run with no registry to consult, which a run from here never is: + +```sh +repo-config/configure.sh apply owner/repo release|operational +``` + +Then validate the result with `repo-config/configure.sh check owner/repo release|operational`, run from the same checkout, which asserts every applied ruleset, setting, and security feature and exits non-zero on drift (the ruleset and settings checks are driven by the committed payloads, so they stay repo-agnostic). Or import each ruleset by hand with `gh api -X POST repos/<owner>/<repo>/rulesets --input repo-config/<name>.json` (operational repos use `operational/develop.json` for `develop`). `gh ruleset` is read-only, so creation goes through `gh api`. The required check binds by name and only turns green after the repo's PR workflow runs once. To edit a live ruleset, GET it, change the field, and PUT the whole writable subset back (a partial PUT `422`s). + +## Regenerating the Payloads + +To change the canonical rulesets, edit the live rulesets (fleet-wide changes happen at the hub), then regenerate the committed files from the current repo: + +```sh +repo="$(gh repo view --json nameWithOwner --jq '.nameWithOwner')" +# Paginate so a name match on a later page is never missed - the same trap configure.sh guards against. +# --paginate with --jq '.[]' emits one JSON object per ruleset across all pages; jq -s re-assembles them +# into the single array the selections below expect. +rulesets=$(gh api --paginate "repos/$repo/rulesets" --jq '.[]' | jq -s '.') +for name in develop main; do + out="repo-config/$name.json" + # An operational carry keeps its develop payload at operational/develop.json (develop.json is absent). + [ "$name" = "develop" ] && [ ! -f "$out" ] && out="repo-config/operational/develop.json" + # Exactly one ruleset per name: zero or duplicates is declared drift - fail loudly, never regen from a guess. + count=$(jq --arg n "$name" '[.[] | select(.name==$n)] | length' <<<"$rulesets") + [ "$count" -eq 1 ] || { echo "expected exactly 1 ruleset named $name, found $count (drift)" >&2; exit 1; } + id=$(jq --arg n "$name" '.[] | select(.name==$n) | .id' <<<"$rulesets") + # bypass_actors is left out, since a payload that declared one would assert this repo's bypass list against every repo diffed on it. + gh api "repos/$repo/rulesets/$id" \ + --jq '{name, target, enforcement, conditions, rules}' \ + | jq -S --indent 4 '.' > "$out" +done +``` + +## Brownfield Migration (Maintainer Only) + +`Require signed commits` rejects any pre-existing unsigned commit, so the first `develop -> main` release on a repo with unsigned history is blocked. Re-signing that history is a non-fast-forward that the `Block force pushes` rule rejects, **and the admin bypass does not cover `git push --force`**. Completing it requires temporarily disabling the ruleset and a maintainer force-push. This is a one-time, maintainer-performed migration that deliberately uses the force-push [GOVERNANCE.md "Git and Commit Rules"][governance-git-and-commit-rules] forbids agents from running. **An agent must never execute it, and must surface it to the maintainer instead.** Greenfield repos where signing is live before the first commit never hit this. When the rewrite touches commits committed under a bot or web-flow identity (`dependabot[bot]`, `github-actions[bot]`), set each commit's committer to the signing identity before re-signing so the committer GitHub verifies matches your key, rather than leaving your key over another identity's commit (see the history-rewrite rule in [GOVERNANCE.md "Git and Commit Rules"][governance-git-and-commit-rules]). + +<!-- Repo --> + +[conformance-matrix]: ../reports/conformance-matrix.md +[governance-documentation-style]: ../GOVERNANCE.md#documentation-style-conventions +[governance-git-and-commit-rules]: ../GOVERNANCE.md#git-and-commit-rules +[governance-hub-hosted-tooling]: ../GOVERNANCE.md#hub-hosted-tooling +[repo-config-readme]: ../repo-config/README.md diff --git a/docs/reusable-workflows.md b/docs/reusable-workflows.md index 64b9a46f..1c36c5c1 100644 --- a/docs/reusable-workflows.md +++ b/docs/reusable-workflows.md @@ -1,256 +1,256 @@ -# Hub-Hosted Reusable Workflows (Hub-Only) - -The design for moving the fleet's standard GitHub Actions workflows out of every repo and into the hub, once, as reusable workflows a downstream repo reaches rather than carries. This doc is **hub-only** and is not carried downstream. It states the target model, the hook contract, the pin and secrets policy, the staged rollout with its completion state, and the measurement that tracks the burn-down. [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling] is the law this doc applies to workflows. [WORKFLOW.md][workflow] keeps the CI/CD contract every hosted workflow still has to satisfy. - -## Table of Contents <!-- omit from toc --> - -- [Why](#why) -- [Measured State](#measured-state) -- [Target Model](#target-model) - - [Layers](#layers) - - [The Hook Contract](#the-hook-contract) - - [Pinning](#pinning) - - [Secrets and Permissions](#secrets-and-permissions) - - [The Hub Is Its Own First Consumer](#the-hub-is-its-own-first-consumer) -- [Hook Catalog](#hook-catalog) -- [The Docker Family](#the-docker-family) -- [Rollout](#rollout) - - [Stage 0: Design, Measurement, and the Merge-Bot Task](#stage-0-design-measurement-and-the-merge-bot-task) - - [Stage 1: Merge-Bot Adoption](#stage-1-merge-bot-adoption) - - [Stage 2: The Gates](#stage-2-the-gates) - - [Stage 3: The Pure Functions](#stage-3-the-pure-functions) - - [Stage 4: The Release Chain and the Docker Core](#stage-4-the-release-chain-and-the-docker-core) - - [Stage 5: The Type-Specific Tasks](#stage-5-the-type-specific-tasks) -- [Adopting the Merge-Bot](#adopting-the-merge-bot) -- [What a Pilot Proves](#what-a-pilot-proves) -- [Open Decisions](#open-decisions) - -## Why - -A standard workflow copied into twenty repos is twenty files that go stale at twenty different rates. A fix to a shared job is a fleet sweep rather than one edit, and a defect in a snippet seeds itself into every repo that adopted it. The carry-versus-reach test in [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling] already decides this for scripts: a file holding no per-repo content is a copy whose only future is to go stale. A workflow whose job graph is identical across repos of a type is that file. - -The audit today grades a carried workflow at `interface` fidelity, meaning it checks the job keys and the artifact seam and leaves the body owned. That was the strongest contract the schema could express for a copied file. Two of the most-copied files, `publish-release.yml` and `validate-task.yml`, could not even take that, because their job lists come in six and twelve shapes across the fleet. Hosting the job graph in the hub is the contract the schema could not express. - -## Measured State - -`python3 spec/workflow_reuse.py --report` reads every cataloged repo's `.github/workflows/` from its ground-truth branch. It compares each file against the hub canonical of the same name, after the normalization the verbatim engine applies. It clusters the copies of each canonical into variants and writes [reports/workflow-reuse.md][workflow-reuse-report]. That report is the burn-down. Its fleet total names the files, the lines, the share of lines byte-identical to a canonical, and the files that already reach a hub reusable workflow or composite action. The migration is done when the copies of each standard workflow reach zero and the callers reach the number of repos that need it. - -The first run, at hub `7c67328` on 2026-08-15 before the merge-bot moved, read 108 workflow files and 10,964 lines across 20 downstream repos. 5,385 of those lines, 49 percent, were byte-identical to a hub canonical, and the rest is mostly a per-repo edit of the same canonical rather than independent code. One file reaches the hub, PhotoCleaner's `validate-task.yml` consuming the `prose-gate` action. Every other copy carries its job bodies. - -The identical share is read against the hub's current canonical, so it falls twice for one workflow: once when the hub canonical becomes a caller stub, since every copy still carrying the job bodies stops matching it, and again when the copies adopt the stub and their lines leave the fleet. The committed report is the second reading for the merge-bot, at hub `47c0c28`, where the identical share is 4,201 lines, 38 percent, with the files, the lines, and the callers unchanged. The three numbers to watch across phases are therefore the files, the lines, and the callers, and the identical share is the duplication that remains inside them. - -## Target Model - -A workflow whose job graph is identical across repos of a type is reached, not carried. The hub hosts it as a `workflow_call` reusable workflow. A downstream repo carries a caller stub, meaning the trigger, the concurrency group, one pinned `uses:`, an explicit `secrets:` map, and a `with:` block for the inputs it sets. Where a repo has behavior of its own, it carries a composite action at the conventional hook path. Everything else a repo carries today for that workflow is deleted. - -### Layers - -1. **The hub reusable workflow**, at `.github/workflows/<name>-task.yml` in the hub. It follows [GOVERNANCE.md "Workflow YAML Conventions"][governance-workflow-yaml-conventions], so the file ends `-task.yml` and its `name:` ends "task". It owns the job graph, the permissions each job needs, the validate-at-entry step, the artifact seam, retention, and the ruleset-bound aggregator name. It checks out the caller's repo by default. When it needs its own defaults or scripts, it checks out the hub at `${{ github.job_workflow_sha }}` under `.hub/`, which is the commit the caller pinned. -2. **The hook**, a composite action at `.github/actions/<hook>/action.yml` in the caller's repo. A hub job resolves it in one order: the caller's path when `hashFiles('.github/actions/<hook>/action.yml')` is non-empty, else the hub default at the same name under `.hub/`. A required hook with no default fails its job with `::error::` naming the missing path. -3. **The caller stub**, downstream, under thirty lines. The audit grades it at `interface` fidelity: the caller job key, the hub task the `uses:` names, and the secrets it maps are the contract, and the `with:` block is the repo's own. -4. **The hub's own use.** The hub calls its own task files by `./` path, so every hub pull request exercises the reusable file at least at parse level, and fully for the workflows the hub itself runs. - -### The Hook Contract - -A hook receives the fixed inputs [WORKFLOW.md "Reusable-Task Parameter Contract"][workflow-reusable-task-parameter-contract] gives a leaf today. Those are `ref`, `branch`, `smoke` where relevant, and the NBGV version outputs where a build needs them. It reports back through step outputs and, for a release target, through the existing artifact seam, `release-asset-<branch>-<target>`, which the hub's `github-release` job collects by pattern. A hook may use marketplace actions, which is the reason a hook is a composite action rather than a script. A toolchain setup, a Docker build, or a coverage upload is a `uses:` step, and a shell script cannot carry one. Action pins inside a hook follow the SHA-pinning rule like any other workflow content, and Dependabot bumps them in the repo that carries the hook. - -### Pinning - -A downstream `uses:` reads `ptr727/ProjectTemplate/.github/workflows/<name>-task.yml@<sha> # <tag>`. The SHA is the hub `main` commit a release tag points at, and the comment is that tag. Hub tags carry no `v`, so the comment reads `# 2.0.334` rather than `# v2.0.334`. A `develop` SHA is not bumpable, because Dependabot compares the pinned commit against tags and a commit no tag names has no version to bump from, which PhotoCleaner's `prose-gate` pin documents in place. Dependabot's `github-actions` ecosystem keeps reusable-workflow references current the same way it keeps action pins current, so a released pin bumps on the same schedule as every other action in the repo. The first such bump in the fleet is the live proof of that sentence. - -A downstream pull request may pin a hub feature-branch SHA to test a hub change that is still in flight, and re-pins to a released `main` SHA before it merges. `scripts/repo_gate.py check_sha_pin` reads the owner and repository out of the reference and confirms the SHA resolves, for a reusable workflow exactly as for an action. - -The sequencing consequence is that a hub task lands on `develop`, promotes to `main`, and is released before any downstream carries a bumpable pin. The catalog snippet for a caller stub therefore lands one release after the task it names, since a snippet under `catalog/snippets/workflows/` is scanned by the same pin gate and cannot carry a placeholder SHA. The [Rollout](#rollout) section carries that ordering as checkboxes per stage. - -### Secrets and Permissions - -Every hub task declares the secrets it needs by name under `on.workflow_call.secrets`, each `required: true`, and a caller maps each one explicitly. `secrets: inherit` is not used, since it is documented for a caller in the same organization or enterprise as the called workflow and the fleet is a personal account. The declared names are the ones [`spec/secrets.json`][secrets] already declares for the mechanism the task implements, so the secret audit and the workflow agree by construction. - -A hub task declares no job-level `permissions:` where every write goes through the App token, and the caller sets `permissions: {}`. A called workflow can only keep or reduce the caller's grant. A callee job naming a scope the caller did not grant fails at startup even when its `if:` is false. Declaring nothing in the callee is therefore the shape that cannot fail against any caller, and it gives `GITHUB_TOKEN` no scope. A task whose job genuinely writes with `GITHUB_TOKEN`, such as a release upload, declares that scope in the callee job and documents it in the stub's comment so the caller grants it. - -### The Hub Is Its Own First Consumer - -The hub's own `.github/workflows/` carries the caller stubs it needs, each calling its task by `./` path. The stub is byte-shaped like a downstream stub apart from that one line, so a change to a task is felt in the hub's own CI first. This is also what makes the hook fallback path run on every hub pull request. The hub carries no hook of its own for a job with a default, so the default runs here on every change. - -## Hook Catalog - -The target set. A row exists once its hub task ships, and until then the row is the plan. - -| Hub task | Hooks, at `.github/actions/<hook>` in the caller | Hub default | -| --- | --- | --- | -| `merge-bot-task.yml` | none, extra bot rules are a `with:` input | not applicable | -| `validate-task.yml` | `validate` (repo tests and lint beyond the fleet doc-lint block) | no-op | -| `test-pull-request-task.yml` | none, wires validate, smoke and the aggregator, `smoke` is a boolean input | not applicable | -| `get-version-task.yml`, `publish-plan-task.yml` | none | not applicable | -| `build-release-task.yml` | `build-executable`, `build-nuget`, `build-pypi`, `release-assets` (extra files) | executable, nuget and pypi defaults from today's snippets | -| `build-docker-task.yml` | `docker-prepare` (extra tags, build-args, matrix), `docker-build-base` | vanilla single-target from `image`, base build required when `build-base` | -| `publish-docker-readme-task.yml` | `docker-readme-transform` | publish `Docker/README.md` or `README.md` as-is | -| `publish-release-task.yml` | none, trigger policy stays in the caller stub and reaches the plan job as `event_name`, `actor` and `ref_name` | not applicable | -| `check-upstream-version-task.yml` | `resolve-upstream` | none, required | -| `deploy-site-task.yml`, `codegen-task.yml` | `deploy`, `codegen` | none, required | - -## The Docker Family - -The five live `build-docker-task.yml` copies share an identical core. It is QEMU and Buildx setup, a Docker Hub login on every build for the higher rate limit, and `docker/build-push-action` with a `type=registry` `buildcache-<branch>` cache. It tags `latest` or `develop` plus `SemVer2`, passes a `LABEL_VERSION` build-arg, and pushes the Docker Hub description on a `main` publish. What varies is data or a pre-step, never the core. - -- **Vanilla single-target** repos differ only in the image name and the build-arg list. That is data, carried in the stub's `with:`. -- **Upstream-pinned** repos read a committed upstream version file before the build and add a `:<upstream-version>` tag and version build-args. That is a `docker-prepare` hook. -- **Multi-image** repos read a matrix file, optionally build base images first, then build each image with its own tags, args and cache repository. That is a `docker-prepare` hook emitting the matrix, plus a `docker-build-base` hook the task calls when `build-base` is set. - -The hub task takes `push`, `ref`, `branch`, `smoke`, the NBGV version outputs, `image`, an optional `matrix` (a JSON list of `{name, tags, build-args, context, dockerfile, cache-repo}`, defaulting to the single entry `image` implies), and `build-base`. The core job body stays hub-owned, so the cache policy, the multi-arch platform selection (`linux/amd64,linux/arm64` on a non-smoke `main` build), the login-on-smoke, and the description push are decided once. - -Docker Hub README publishing is a hub task of its own, `publish-docker-readme-task.yml`, with the size-limited overview, the repository list, and a `docker-readme-transform` hook in place of today's `transform-run` string input. The in-job description push in the build task is dropped in its favor, so the readme publishes once per release rather than once per image build. Upstream dependency monitoring is one hub task, `check-upstream-version-task.yml`, with a `resolve-upstream` hook in place of today's `resolver-command` string input and an `auto-merge` input. A tracker whose bump must wait for a human sets `auto-merge: false`, which gives the pull request a head prefix the merge-bot rules do not match. The rebuild-on-upstream-change trigger stays in the caller stub as a `push` filtered to the state file. Multi-stage Dockerfile builds are inconsistent across the Docker repos, and that is Dockerfile content rather than workflow content, so it is tracked as a type-level improvement beside this work rather than inside it. - -## Rollout - -This section is the tracker a session resumes from, and git is its only persistence. Every item is a checkbox with the evidence that closed it, a pull request, a commit, or a release tag, written into the item by the change that closed it. A session picking this work up reads this section first, takes the first unchecked item whose stage is open, verifies its claim against the current tree before acting on it, does the work in its own worktree, and ticks the item in the same pull request. Nothing here is ticked by intention: an item is ticked when the thing it names is on `develop`, or, for an adoption, on the named repo's ground-truth branch. [`TODO.md`][todo] "Hub-Hosted Reusable Workflows" carries the reasoning behind each stage, the open questions and what is settled, and this section carries the state. - -A stage carries three kinds of item, plus a proof item where a claim needs a live run. **Hub** is the hub pull request that ships the task and its stub, and the catalog snippet that follows the release. **Release** is the promotion and release that gives the task a pinnable `main` commit, since a downstream stub pins a released tag and nothing can adopt before one exists. **Adoption** is one checkbox per repo, ticked when that repo's ground-truth branch carries the stub and the audit reports no `interface` finding on the file. **Proof** is a checkbox for a behavior only a live run demonstrates, ticked with the run URL. Stage 0 is the merge-bot's hub and release items plus its two proofs, and stage 1 is its adoption, split so the adoption list is a stage of its own. The exit metric per stage comes from [reports/workflow-reuse.md][workflow-reuse-report]: downstream copies of the stage's files fall to zero, callers rise to the number of repos that need the workflow, and downstream workflow lines fall from 10,964 toward the stubs plus the genuinely repo-specific hooks. Regenerate that report in the pull request that ticks a stage's last adoption, so the number and the tick land together. - -### Stage 0: Design, Measurement, and the Merge-Bot Task - -- [x] `docs/reusable-workflows.md`, `spec/workflow_reuse.py`, `reports/workflow-reuse.md`, `.github/workflows/merge-bot-task.yml`, the hub's own caller stub, and the manifest contract for `merge-bot-pull-request.yml`, on `develop` in #744 (`f33fa7e`). -- [x] Promoted to `main` in #746 (`20616e0`) and released as `2.0.338`, the first tag carrying `merge-bot-task.yml`. -- [x] The catalog caller snippet `catalog/snippets/workflows/merge-bot-pull-request.yml`, pinned to `20616e0a70613ad8727d567990f5d0e082f5275c # 2.0.338`, in #748, the pull request that added this section. -- [ ] The first Dependabot pull request against hub `develop` after `f33fa7e` merges through `merge-bot-task.yml`, proving the callee reads the caller's `pull_request_target` payload, the explicit `secrets:` map, App-token minting in a callee, and `permissions: {}` at the caller. Tick with the run URL. If it fails on the token grant, the fallback is at the caller, since a callee cannot widen what its caller grants: replace `permissions: {}` with `contents: read`, the least scope, and widen only to what the failing run names. -- [ ] The first Dependabot pull request against hub `main` after `20616e0` merges with `--merge`. Tick with the run URL. - -### Stage 1: Merge-Bot Adoption - -Adoptable since `2.0.338`. Each repo replaces the whole of its `.github/workflows/merge-bot-pull-request.yml` with the stub in [Adopting the Merge-Bot](#adopting-the-merge-bot), on its own feature branch, and the audit's `missing required job 'merge-bot'` finding on that file is the work list. The pilot goes first and records what the hub cannot prove, cross-repository resolution of the pin, the `rules` input where the repo has a tracker, and the first Dependabot bump of the pin, in its audit report. - -- [ ] HomeAutomation-Config (pilot, operational model) -- [ ] homeassistant-purpleair (second, `rules: '[{"head-prefix": "ha-version-bump/", "base": "develop"}]'` and `delete-branch: true`) -- [ ] ESPHome-NonRoot (`delete-branch: true`, built-in upstream-version pairs cover its tracker) -- [ ] NxWitness (`delete-branch: true`, drops the Dependabot semver-major filter per D8.1 unless the open decision lands first) -- [ ] KiCadLibrary (drops the Dependabot semver-major filter per D8.1 unless the open decision lands first) -- [ ] LanguageTags (`delete-branch: true`) -- [ ] aiopurpleair (`delete-branch: true`) -- [ ] MediaTools (`delete-branch: true`) -- [ ] VSCode-Server-DotNetCore (`delete-branch: true`) -- [ ] Blog -- [ ] ESPHome-Config -- [ ] HomeAssistant-Config -- [ ] PhotoCleaner -- [ ] PlexCleaner -- [ ] Utilities -- [ ] Vantage-Config -- [ ] AudioCleaner (carries no merge-bot today, takes the stub on its next standup or resync, since the manifest applies it to every repo) -- [ ] DevKitCIoT (same) -- [ ] EspDinIoT (same) -- [ ] Financial-Modeling (same) -- [ ] HolidayLights (same) -- [ ] `reports/workflow-reuse.md` regenerated with `merge-bot-pull-request.yml` at 0 copies carrying job bodies and callers equal to the adopters. - -### Stage 2: The Gates - -Hub: `validate-task.yml` hosts the per-type doc-lint block once and calls the `validate` hook for a repo's own tests, deciding #729 in the one place the `uvx` tools are pinned or floated. `test-pull-request-task.yml` wires validate, smoke and the fixed aggregator name, and the stub carries the trigger shape, release or operational, which settles #585. This stage is where the hook fallback is first proven live: the hub carries no hook, so the default runs on every hub pull request, and the pilot's hook proves the override. - -- [ ] Hub pull request on `develop` with both tasks, the hub's own stubs, the manifest contracts, and the catalog snippets left for the release that follows. -- [ ] Promoted and released, tag recorded here. -- [ ] Catalog snippets for both stubs pinned to that release. -- [ ] Hook fallback observed on a hub pull request run (default path) and on the pilot (override path), run URLs recorded here. -- [ ] HomeAutomation-Config (pilot, operational trigger shape) -- [ ] One C# repo (second pilot, release trigger shape with smoke) -- [ ] The remaining repos, one checkbox each added when the pilots close, since the sweep list is every cataloged repo. -- [ ] `reports/workflow-reuse.md` regenerated with `validate-task.yml` and `test-pull-request.yml` at 0 copies carrying job bodies. - -### Stage 3: The Pure Functions - -Hub: `get-version-task.yml` and `publish-plan-task.yml` hosted, and the downstream copies deleted on adoption. PlexCleaner gains the `plan` job D4.1 requires by adopting rather than by a copy. - -- [ ] Hub pull request on `develop`. -- [ ] Promoted and released, tag recorded here. -- [ ] Adoption, one checkbox per carrier added when the hub pull request merges: today `get-version-task.yml` has 8 carriers and `publish-plan-task.yml` 3. -- [ ] `reports/workflow-reuse.md` regenerated with both at 0 copies. - -### Stage 4: The Release Chain and the Docker Core - -Hub: `build-release-task.yml` with `build-executable`, `build-nuget`, `build-pypi` and `release-assets` hooks, `publish-release-task.yml`, and `build-docker-task.yml` per [The Docker Family](#the-docker-family). The three no-asset release shapes collapse into `expect_release_assets`. - -- [ ] Hub pull request on `develop`. -- [ ] Promoted and released, tag recorded here. -- [ ] PhotoCleaner and PlexCleaner (pilots, vanilla Docker plus executable) -- [ ] VSCode-Server-DotNetCore (vanilla Docker only) -- [ ] ESPHome-NonRoot (`docker-prepare` hook for the upstream pin) -- [ ] NxWitness (matrix hook and `build-base`) -- [ ] The NuGet, PyPI and remaining release repos, one checkbox each added when the pilots close. -- [ ] `reports/workflow-reuse.md` regenerated with `build-release-task.yml`, `publish-release.yml` and `build-docker-task.yml` at 0 copies. - -### Stage 5: The Type-Specific Tasks - -Hub: `publish-docker-readme-task.yml` with a `docker-readme-transform` hook, `check-upstream-version-task.yml` with a `resolve-upstream` hook and an `auto-merge` input, deploy-site, codegen and the date badge, each with its hook. The `operational-vs-release-workflow` skill's note that the target list stays per repo is retired here. - -- [ ] Hub pull request on `develop`. -- [ ] Promoted and released, tag recorded here. -- [ ] Adoption, one checkbox per carrier added when the hub pull request merges. -- [ ] `reports/workflow-reuse.md` regenerated, and the fleet total's callers equal to the sum of the stubs the fleet needs. - -## Adopting the Merge-Bot - -A downstream repo replaces the whole of its `.github/workflows/merge-bot-pull-request.yml` with the stub below, which is the catalog snippet `catalog/snippets/workflows/merge-bot-pull-request.yml` byte for byte, and deletes nothing else. The pin is the release that first carried the task, and Dependabot bumps it from there. Its App-signed pull requests keep merging by the built-in rules (`codegen-main` to `main`, `codegen-develop` to `develop`, `upstream-version-main` to `main`, `upstream-version-develop` to `develop`). A repo with a tracker outside those pairs adds one `rules` entry per pair, and a repo that keeps its repository-wide branch auto-delete off and still wants bot branches gone sets `delete-branch: true`. - -```yaml -name: Merge bot pull request action - -# Thin caller: the merge-bot is the hub's reusable merge-bot-task.yml, which every fleet repo reaches rather than carries. -# The trigger is pull_request_target so the called workflow resolves from the trusted base rather than the PR head, and no job checks out PR code. -on: - pull_request_target: - types: [opened, reopened, synchronize] - -# Concurrency keys on the PR number rather than on github.ref, which under pull_request_target is the base branch and would serialize every bot PR against it, so each PR queues independently. -# The cancel-in-progress setting is false so a follow-up synchronize does not cancel an in-flight opened run before it enables auto-merge. -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} - cancel-in-progress: false - -# Every write in the called workflow uses the App token, so GITHUB_TOKEN gets no scope. -permissions: {} - -jobs: - - merge-bot: - name: Merge bot pull request job - uses: ptr727/ProjectTemplate/.github/workflows/merge-bot-task.yml@20616e0a70613ad8727d567990f5d0e082f5275c # 2.0.338 - secrets: - CODEGEN_APP_CLIENT_ID: ${{ secrets.CODEGEN_APP_CLIENT_ID }} - CODEGEN_APP_PRIVATE_KEY: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} - # A repo with a tracker outside the built-in codegen and upstream-version pairs adds a with: block carrying a rules JSON array of head or head-prefix plus base. - # A repo that keeps the repository-wide auto-delete off and still wants bot branches gone sets delete-branch true in the same block. -``` - -A repo that needs either input appends the block to the `merge-bot` job. This is the shape for a repo with a `ha-version-bump/` tracker into `develop` that also wants its bot branches deleted: - -```yaml - with: - rules: '[{"head-prefix": "ha-version-bump/", "base": "develop"}]' - delete-branch: true -``` - -The task's inputs are `app-login` (default `ptr727-codegen[bot]`), `rules` (a JSON array of `{"head": "<exact>"}` or `{"head-prefix": "<prefix>"}` plus `"base"`, default `[]`), and `delete-branch` (default `false`). The merge method follows the base, `develop` squashes and `main` merges, so a rule carries none. An App pull request that matches no rule is annotated with a warning rather than merged, so a renamed tracker branch is visible in the run rather than silent. - -Two copies today filter Dependabot by ecosystem and semver tier before merging. [WORKFLOW.md D8.1][workflow-d8] says every Dependabot tier auto-merges and the required checks are the gate, so those two repos drop the filter on adoption unless the [Open Decisions](#open-decisions) below settle otherwise. - -## What a Pilot Proves - -The hub's own stub proves most of the mechanics on the first Dependabot pull request after the task lands on `develop`. That run shows the callee reading the caller's `github.event.*` under `pull_request_target`. It shows an explicit `secrets:` map reaching the callee and the App token minting inside one. It shows `permissions: {}` at the caller not failing the callee at startup, and `--squash` running on `develop`. A Dependabot pull request against `main` after promotion proves `--merge`, and a maintainer push to a bot branch proves the disable job. A hub feature branch cannot test itself, since under `pull_request_target` the callee resolves from the base branch, so the proof follows the merge rather than preceding it. - -Four things the hub cannot prove fall to the first downstream adopter. They are cross-repository resolution of the owner-scoped `uses:` reference, Dependabot bumping a `# <tag>` pin on a reusable workflow, and the `rules` input end to end on a repo with a tracker. The fourth is `merge-app` itself, since nothing opens App pull requests against the hub. A pilot records each of those as observed in its own audit report rather than assumed here. - -## Open Decisions - -- **`delete-branch` default.** `false` matches the hub's behavior, and seven repos opt in today. A fleet default of `true` is one edit to the task and removes seven `with:` blocks. The repository setting that protects `develop` from a promotion is unaffected either way, since a bot branch is never `develop`. -- **The Dependabot semver-major filter.** Two repos skip a nuget semver-major bump. Either it drops on adoption per D8.1, or the task grows a `skip-semver-major-ecosystems` input with a `dependabot/fetch-metadata` step run under the App token. Decide before those two repos adopt, everything else adopts unaffected. -- **A `requiredHubUses` audit contract.** The interface check today asserts the task filename token in the caller job. A field asserting the full owner-scoped form on a downstream copy and the `./` form on the hub is a small schema extension. It waits for the first adoption to show whether the token check misses anything. - -<!-- Repo --> - -[governance-hub-hosted-tooling]: ../GOVERNANCE.md#hub-hosted-tooling -[governance-workflow-yaml-conventions]: ../GOVERNANCE.md#workflow-yaml-conventions -[secrets]: ../spec/secrets.json -[workflow]: ../WORKFLOW.md -[workflow-d8]: ../WORKFLOW.md#d8---bots--automation -[workflow-reusable-task-parameter-contract]: ../WORKFLOW.md#reusable-task-parameter-contract -[todo]: ../TODO.md -[workflow-reuse-report]: ../reports/workflow-reuse.md +# Hub-Hosted Reusable Workflows (Hub-Only) + +The design for moving the fleet's standard GitHub Actions workflows out of every repo and into the hub, once, as reusable workflows a downstream repo reaches rather than carries. This doc is **hub-only** and is not carried downstream. It states the target model, the hook contract, the pin and secrets policy, the staged rollout with its completion state, and the measurement that tracks the burn-down. [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling] is the law this doc applies to workflows. [WORKFLOW.md][workflow] keeps the CI/CD contract every hosted workflow still has to satisfy. + +## Table of Contents <!-- omit from toc --> + +- [Why](#why) +- [Measured State](#measured-state) +- [Target Model](#target-model) + - [Layers](#layers) + - [The Hook Contract](#the-hook-contract) + - [Pinning](#pinning) + - [Secrets and Permissions](#secrets-and-permissions) + - [The Hub Is Its Own First Consumer](#the-hub-is-its-own-first-consumer) +- [Hook Catalog](#hook-catalog) +- [The Docker Family](#the-docker-family) +- [Rollout](#rollout) + - [Stage 0: Design, Measurement, and the Merge-Bot Task](#stage-0-design-measurement-and-the-merge-bot-task) + - [Stage 1: Merge-Bot Adoption](#stage-1-merge-bot-adoption) + - [Stage 2: The Gates](#stage-2-the-gates) + - [Stage 3: The Pure Functions](#stage-3-the-pure-functions) + - [Stage 4: The Release Chain and the Docker Core](#stage-4-the-release-chain-and-the-docker-core) + - [Stage 5: The Type-Specific Tasks](#stage-5-the-type-specific-tasks) +- [Adopting the Merge-Bot](#adopting-the-merge-bot) +- [What a Pilot Proves](#what-a-pilot-proves) +- [Open Decisions](#open-decisions) + +## Why + +A standard workflow copied into twenty repos is twenty files that go stale at twenty different rates. A fix to a shared job is a fleet sweep rather than one edit, and a defect in a snippet seeds itself into every repo that adopted it. The carry-versus-reach test in [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling] already decides this for scripts: a file holding no per-repo content is a copy whose only future is to go stale. A workflow whose job graph is identical across repos of a type is that file. + +The audit today grades a carried workflow at `interface` fidelity, meaning it checks the job keys and the artifact seam and leaves the body owned. That was the strongest contract the schema could express for a copied file. Two of the most-copied files, `publish-release.yml` and `validate-task.yml`, could not even take that, because their job lists come in six and twelve shapes across the fleet. Hosting the job graph in the hub is the contract the schema could not express. + +## Measured State + +`python3 spec/workflow_reuse.py --report` reads every cataloged repo's `.github/workflows/` from its ground-truth branch. It compares each file against the hub canonical of the same name, after the normalization the verbatim engine applies. It clusters the copies of each canonical into variants and writes [reports/workflow-reuse.md][workflow-reuse-report]. That report is the burn-down. Its fleet total names the files, the lines, the share of lines byte-identical to a canonical, and the files that already reach a hub reusable workflow or composite action. The migration is done when the copies of each standard workflow reach zero and the callers reach the number of repos that need it. + +The first run, at hub `7c67328` on 2026-08-15 before the merge-bot moved, read 108 workflow files and 10,964 lines across 20 downstream repos. 5,385 of those lines, 49 percent, were byte-identical to a hub canonical, and the rest is mostly a per-repo edit of the same canonical rather than independent code. One file reaches the hub, PhotoCleaner's `validate-task.yml` consuming the `prose-gate` action. Every other copy carries its job bodies. + +The identical share is read against the hub's current canonical, so it falls twice for one workflow: once when the hub canonical becomes a caller stub, since every copy still carrying the job bodies stops matching it, and again when the copies adopt the stub and their lines leave the fleet. The committed report is the second reading for the merge-bot, at hub `47c0c28`, where the identical share is 4,201 lines, 38 percent, with the files, the lines, and the callers unchanged. The three numbers to watch across phases are therefore the files, the lines, and the callers, and the identical share is the duplication that remains inside them. + +## Target Model + +A workflow whose job graph is identical across repos of a type is reached, not carried. The hub hosts it as a `workflow_call` reusable workflow. A downstream repo carries a caller stub, meaning the trigger, the concurrency group, one pinned `uses:`, an explicit `secrets:` map, and a `with:` block for the inputs it sets. Where a repo has behavior of its own, it carries a composite action at the conventional hook path. Everything else a repo carries today for that workflow is deleted. + +### Layers + +1. **The hub reusable workflow**, at `.github/workflows/<name>-task.yml` in the hub. It follows [GOVERNANCE.md "Workflow YAML Conventions"][governance-workflow-yaml-conventions], so the file ends `-task.yml` and its `name:` ends "task". It owns the job graph, the permissions each job needs, the validate-at-entry step, the artifact seam, retention, and the ruleset-bound aggregator name. It checks out the caller's repo by default. When it needs its own defaults or scripts, it checks out the hub at `${{ github.job_workflow_sha }}` under `.hub/`, which is the commit the caller pinned. +2. **The hook**, a composite action at `.github/actions/<hook>/action.yml` in the caller's repo. A hub job resolves it in one order: the caller's path when `hashFiles('.github/actions/<hook>/action.yml')` is non-empty, else the hub default at the same name under `.hub/`. A required hook with no default fails its job with `::error::` naming the missing path. +3. **The caller stub**, downstream, under thirty lines. The audit grades it at `interface` fidelity: the caller job key, the hub task the `uses:` names, and the secrets it maps are the contract, and the `with:` block is the repo's own. +4. **The hub's own use.** The hub calls its own task files by `./` path, so every hub pull request exercises the reusable file at least at parse level, and fully for the workflows the hub itself runs. + +### The Hook Contract + +A hook receives the fixed inputs [WORKFLOW.md "Reusable-Task Parameter Contract"][workflow-reusable-task-parameter-contract] gives a leaf today. Those are `ref`, `branch`, `smoke` where relevant, and the NBGV version outputs where a build needs them. It reports back through step outputs and, for a release target, through the existing artifact seam, `release-asset-<branch>-<target>`, which the hub's `github-release` job collects by pattern. A hook may use marketplace actions, which is the reason a hook is a composite action rather than a script. A toolchain setup, a Docker build, or a coverage upload is a `uses:` step, and a shell script cannot carry one. Action pins inside a hook follow the SHA-pinning rule like any other workflow content, and Dependabot bumps them in the repo that carries the hook. + +### Pinning + +A downstream `uses:` reads `ptr727/ProjectTemplate/.github/workflows/<name>-task.yml@<sha> # <tag>`. The SHA is the hub `main` commit a release tag points at, and the comment is that tag. Hub tags carry no `v`, so the comment reads `# 2.0.334` rather than `# v2.0.334`. A `develop` SHA is not bumpable, because Dependabot compares the pinned commit against tags and a commit no tag names has no version to bump from, which PhotoCleaner's `prose-gate` pin documents in place. Dependabot's `github-actions` ecosystem keeps reusable-workflow references current the same way it keeps action pins current, so a released pin bumps on the same schedule as every other action in the repo. The first such bump in the fleet is the live proof of that sentence. + +A downstream pull request may pin a hub feature-branch SHA to test a hub change that is still in flight, and re-pins to a released `main` SHA before it merges. `scripts/repo_gate.py check_sha_pin` reads the owner and repository out of the reference and confirms the SHA resolves, for a reusable workflow exactly as for an action. + +The sequencing consequence is that a hub task lands on `develop`, promotes to `main`, and is released before any downstream carries a bumpable pin. The catalog snippet for a caller stub therefore lands one release after the task it names, since a snippet under `catalog/snippets/workflows/` is scanned by the same pin gate and cannot carry a placeholder SHA. The [Rollout](#rollout) section carries that ordering as checkboxes per stage. + +### Secrets and Permissions + +Every hub task declares the secrets it needs by name under `on.workflow_call.secrets`, each `required: true`, and a caller maps each one explicitly. `secrets: inherit` is not used, since it is documented for a caller in the same organization or enterprise as the called workflow and the fleet is a personal account. The declared names are the ones [`spec/secrets.json`][secrets] already declares for the mechanism the task implements, so the secret audit and the workflow agree by construction. + +A hub task declares no job-level `permissions:` where every write goes through the App token, and the caller sets `permissions: {}`. A called workflow can only keep or reduce the caller's grant. A callee job naming a scope the caller did not grant fails at startup even when its `if:` is false. Declaring nothing in the callee is therefore the shape that cannot fail against any caller, and it gives `GITHUB_TOKEN` no scope. A task whose job genuinely writes with `GITHUB_TOKEN`, such as a release upload, declares that scope in the callee job and documents it in the stub's comment so the caller grants it. + +### The Hub Is Its Own First Consumer + +The hub's own `.github/workflows/` carries the caller stubs it needs, each calling its task by `./` path. The stub is byte-shaped like a downstream stub apart from that one line, so a change to a task is felt in the hub's own CI first. This is also what makes the hook fallback path run on every hub pull request. The hub carries no hook of its own for a job with a default, so the default runs here on every change. + +## Hook Catalog + +The target set. A row exists once its hub task ships, and until then the row is the plan. + +| Hub task | Hooks, at `.github/actions/<hook>` in the caller | Hub default | +| --- | --- | --- | +| `merge-bot-task.yml` | none, extra bot rules are a `with:` input | not applicable | +| `validate-task.yml` | `validate` (repo tests and lint beyond the fleet doc-lint block) | no-op | +| `test-pull-request-task.yml` | none, wires validate, smoke and the aggregator, `smoke` is a boolean input | not applicable | +| `get-version-task.yml`, `publish-plan-task.yml` | none | not applicable | +| `build-release-task.yml` | `build-executable`, `build-nuget`, `build-pypi`, `release-assets` (extra files) | executable, nuget and pypi defaults from today's snippets | +| `build-docker-task.yml` | `docker-prepare` (extra tags, build-args, matrix), `docker-build-base` | vanilla single-target from `image`, base build required when `build-base` | +| `publish-docker-readme-task.yml` | `docker-readme-transform` | publish `Docker/README.md` or `README.md` as-is | +| `publish-release-task.yml` | none, trigger policy stays in the caller stub and reaches the plan job as `event_name`, `actor` and `ref_name` | not applicable | +| `check-upstream-version-task.yml` | `resolve-upstream` | none, required | +| `deploy-site-task.yml`, `codegen-task.yml` | `deploy`, `codegen` | none, required | + +## The Docker Family + +The five live `build-docker-task.yml` copies share an identical core. It is QEMU and Buildx setup, a Docker Hub login on every build for the higher rate limit, and `docker/build-push-action` with a `type=registry` `buildcache-<branch>` cache. It tags `latest` or `develop` plus `SemVer2`, passes a `LABEL_VERSION` build-arg, and pushes the Docker Hub description on a `main` publish. What varies is data or a pre-step, never the core. + +- **Vanilla single-target** repos differ only in the image name and the build-arg list. That is data, carried in the stub's `with:`. +- **Upstream-pinned** repos read a committed upstream version file before the build and add a `:<upstream-version>` tag and version build-args. That is a `docker-prepare` hook. +- **Multi-image** repos read a matrix file, optionally build base images first, then build each image with its own tags, args and cache repository. That is a `docker-prepare` hook emitting the matrix, plus a `docker-build-base` hook the task calls when `build-base` is set. + +The hub task takes `push`, `ref`, `branch`, `smoke`, the NBGV version outputs, `image`, an optional `matrix` (a JSON list of `{name, tags, build-args, context, dockerfile, cache-repo}`, defaulting to the single entry `image` implies), and `build-base`. The core job body stays hub-owned, so the cache policy, the multi-arch platform selection (`linux/amd64,linux/arm64` on a non-smoke `main` build), the login-on-smoke, and the description push are decided once. + +Docker Hub README publishing is a hub task of its own, `publish-docker-readme-task.yml`, with the size-limited overview, the repository list, and a `docker-readme-transform` hook in place of today's `transform-run` string input. The in-job description push in the build task is dropped in its favor, so the readme publishes once per release rather than once per image build. Upstream dependency monitoring is one hub task, `check-upstream-version-task.yml`, with a `resolve-upstream` hook in place of today's `resolver-command` string input and an `auto-merge` input. A tracker whose bump must wait for a human sets `auto-merge: false`, which gives the pull request a head prefix the merge-bot rules do not match. The rebuild-on-upstream-change trigger stays in the caller stub as a `push` filtered to the state file. Multi-stage Dockerfile builds are inconsistent across the Docker repos, and that is Dockerfile content rather than workflow content, so it is tracked as a type-level improvement beside this work rather than inside it. + +## Rollout + +This section is the tracker a session resumes from, and git is its only persistence. Every item is a checkbox with the evidence that closed it, a pull request, a commit, or a release tag, written into the item by the change that closed it. A session picking this work up reads this section first, takes the first unchecked item whose stage is open, verifies its claim against the current tree before acting on it, does the work in its own worktree, and ticks the item in the same pull request. Nothing here is ticked by intention: an item is ticked when the thing it names is on `develop`, or, for an adoption, on the named repo's ground-truth branch. [`TODO.md`][todo] "Hub-Hosted Reusable Workflows" carries the reasoning behind each stage, the open questions and what is settled, and this section carries the state. + +A stage carries three kinds of item, plus a proof item where a claim needs a live run. **Hub** is the hub pull request that ships the task and its stub, and the catalog snippet that follows the release. **Release** is the promotion and release that gives the task a pinnable `main` commit, since a downstream stub pins a released tag and nothing can adopt before one exists. **Adoption** is one checkbox per repo, ticked when that repo's ground-truth branch carries the stub and the audit reports no `interface` finding on the file. **Proof** is a checkbox for a behavior only a live run demonstrates, ticked with the run URL. Stage 0 is the merge-bot's hub and release items plus its two proofs, and stage 1 is its adoption, split so the adoption list is a stage of its own. The exit metric per stage comes from [reports/workflow-reuse.md][workflow-reuse-report]: downstream copies of the stage's files fall to zero, callers rise to the number of repos that need the workflow, and downstream workflow lines fall from 10,964 toward the stubs plus the genuinely repo-specific hooks. Regenerate that report in the pull request that ticks a stage's last adoption, so the number and the tick land together. + +### Stage 0: Design, Measurement, and the Merge-Bot Task + +- [x] `docs/reusable-workflows.md`, `spec/workflow_reuse.py`, `reports/workflow-reuse.md`, `.github/workflows/merge-bot-task.yml`, the hub's own caller stub, and the manifest contract for `merge-bot-pull-request.yml`, on `develop` in #744 (`f33fa7e`). +- [x] Promoted to `main` in #746 (`20616e0`) and released as `2.0.338`, the first tag carrying `merge-bot-task.yml`. +- [x] The catalog caller snippet `catalog/snippets/workflows/merge-bot-pull-request.yml`, pinned to `20616e0a70613ad8727d567990f5d0e082f5275c # 2.0.338`, in #748, the pull request that added this section. +- [ ] The first Dependabot pull request against hub `develop` after `f33fa7e` merges through `merge-bot-task.yml`, proving the callee reads the caller's `pull_request_target` payload, the explicit `secrets:` map, App-token minting in a callee, and `permissions: {}` at the caller. Tick with the run URL. If it fails on the token grant, the fallback is at the caller, since a callee cannot widen what its caller grants: replace `permissions: {}` with `contents: read`, the least scope, and widen only to what the failing run names. +- [ ] The first Dependabot pull request against hub `main` after `20616e0` merges with `--merge`. Tick with the run URL. + +### Stage 1: Merge-Bot Adoption + +Adoptable since `2.0.338`. Each repo replaces the whole of its `.github/workflows/merge-bot-pull-request.yml` with the stub in [Adopting the Merge-Bot](#adopting-the-merge-bot), on its own feature branch, and the audit's `missing required job 'merge-bot'` finding on that file is the work list. The pilot goes first and records what the hub cannot prove, cross-repository resolution of the pin, the `rules` input where the repo has a tracker, and the first Dependabot bump of the pin, in its audit report. + +- [ ] HomeAutomation-Config (pilot, operational model) +- [ ] homeassistant-purpleair (second, `rules: '[{"head-prefix": "ha-version-bump/", "base": "develop"}]'` and `delete-branch: true`) +- [ ] ESPHome-NonRoot (`delete-branch: true`, built-in upstream-version pairs cover its tracker) +- [ ] NxWitness (`delete-branch: true`, drops the Dependabot semver-major filter per D8.1 unless the open decision lands first) +- [ ] KiCadLibrary (drops the Dependabot semver-major filter per D8.1 unless the open decision lands first) +- [ ] LanguageTags (`delete-branch: true`) +- [ ] aiopurpleair (`delete-branch: true`) +- [ ] MediaTools (`delete-branch: true`) +- [ ] VSCode-Server-DotNetCore (`delete-branch: true`) +- [ ] Blog +- [ ] ESPHome-Config +- [ ] HomeAssistant-Config +- [ ] PhotoCleaner +- [ ] PlexCleaner +- [ ] Utilities +- [ ] Vantage-Config +- [ ] AudioCleaner (carries no merge-bot today, takes the stub on its next standup or resync, since the manifest applies it to every repo) +- [ ] DevKitCIoT (same) +- [ ] EspDinIoT (same) +- [ ] Financial-Modeling (same) +- [ ] HolidayLights (same) +- [ ] `reports/workflow-reuse.md` regenerated with `merge-bot-pull-request.yml` at 0 copies carrying job bodies and callers equal to the adopters. + +### Stage 2: The Gates + +Hub: `validate-task.yml` hosts the per-type doc-lint block once and calls the `validate` hook for a repo's own tests, deciding #729 in the one place the `uvx` tools are pinned or floated. `test-pull-request-task.yml` wires validate, smoke and the fixed aggregator name, and the stub carries the trigger shape, release or operational, which settles #585. This stage is where the hook fallback is first proven live: the hub carries no hook, so the default runs on every hub pull request, and the pilot's hook proves the override. + +- [ ] Hub pull request on `develop` with both tasks, the hub's own stubs, the manifest contracts, and the catalog snippets left for the release that follows. +- [ ] Promoted and released, tag recorded here. +- [ ] Catalog snippets for both stubs pinned to that release. +- [ ] Hook fallback observed on a hub pull request run (default path) and on the pilot (override path), run URLs recorded here. +- [ ] HomeAutomation-Config (pilot, operational trigger shape) +- [ ] One C# repo (second pilot, release trigger shape with smoke) +- [ ] The remaining repos, one checkbox each added when the pilots close, since the sweep list is every cataloged repo. +- [ ] `reports/workflow-reuse.md` regenerated with `validate-task.yml` and `test-pull-request.yml` at 0 copies carrying job bodies. + +### Stage 3: The Pure Functions + +Hub: `get-version-task.yml` and `publish-plan-task.yml` hosted, and the downstream copies deleted on adoption. PlexCleaner gains the `plan` job D4.1 requires by adopting rather than by a copy. + +- [ ] Hub pull request on `develop`. +- [ ] Promoted and released, tag recorded here. +- [ ] Adoption, one checkbox per carrier added when the hub pull request merges: today `get-version-task.yml` has 8 carriers and `publish-plan-task.yml` 3. +- [ ] `reports/workflow-reuse.md` regenerated with both at 0 copies. + +### Stage 4: The Release Chain and the Docker Core + +Hub: `build-release-task.yml` with `build-executable`, `build-nuget`, `build-pypi` and `release-assets` hooks, `publish-release-task.yml`, and `build-docker-task.yml` per [The Docker Family](#the-docker-family). The three no-asset release shapes collapse into `expect_release_assets`. + +- [ ] Hub pull request on `develop`. +- [ ] Promoted and released, tag recorded here. +- [ ] PhotoCleaner and PlexCleaner (pilots, vanilla Docker plus executable) +- [ ] VSCode-Server-DotNetCore (vanilla Docker only) +- [ ] ESPHome-NonRoot (`docker-prepare` hook for the upstream pin) +- [ ] NxWitness (matrix hook and `build-base`) +- [ ] The NuGet, PyPI and remaining release repos, one checkbox each added when the pilots close. +- [ ] `reports/workflow-reuse.md` regenerated with `build-release-task.yml`, `publish-release.yml` and `build-docker-task.yml` at 0 copies. + +### Stage 5: The Type-Specific Tasks + +Hub: `publish-docker-readme-task.yml` with a `docker-readme-transform` hook, `check-upstream-version-task.yml` with a `resolve-upstream` hook and an `auto-merge` input, deploy-site, codegen and the date badge, each with its hook. The `operational-vs-release-workflow` skill's note that the target list stays per repo is retired here. + +- [ ] Hub pull request on `develop`. +- [ ] Promoted and released, tag recorded here. +- [ ] Adoption, one checkbox per carrier added when the hub pull request merges. +- [ ] `reports/workflow-reuse.md` regenerated, and the fleet total's callers equal to the sum of the stubs the fleet needs. + +## Adopting the Merge-Bot + +A downstream repo replaces the whole of its `.github/workflows/merge-bot-pull-request.yml` with the stub below, which is the catalog snippet `catalog/snippets/workflows/merge-bot-pull-request.yml` byte for byte, and deletes nothing else. The pin is the release that first carried the task, and Dependabot bumps it from there. Its App-signed pull requests keep merging by the built-in rules (`codegen-main` to `main`, `codegen-develop` to `develop`, `upstream-version-main` to `main`, `upstream-version-develop` to `develop`). A repo with a tracker outside those pairs adds one `rules` entry per pair, and a repo that keeps its repository-wide branch auto-delete off and still wants bot branches gone sets `delete-branch: true`. + +```yaml +name: Merge bot pull request action + +# Thin caller: the merge-bot is the hub's reusable merge-bot-task.yml, which every fleet repo reaches rather than carries. +# The trigger is pull_request_target so the called workflow resolves from the trusted base rather than the PR head, and no job checks out PR code. +on: + pull_request_target: + types: [opened, reopened, synchronize] + +# Concurrency keys on the PR number rather than on github.ref, which under pull_request_target is the base branch and would serialize every bot PR against it, so each PR queues independently. +# The cancel-in-progress setting is false so a follow-up synchronize does not cancel an in-flight opened run before it enables auto-merge. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: false + +# Every write in the called workflow uses the App token, so GITHUB_TOKEN gets no scope. +permissions: {} + +jobs: + + merge-bot: + name: Merge bot pull request job + uses: ptr727/ProjectTemplate/.github/workflows/merge-bot-task.yml@20616e0a70613ad8727d567990f5d0e082f5275c # 2.0.338 + secrets: + CODEGEN_APP_CLIENT_ID: ${{ secrets.CODEGEN_APP_CLIENT_ID }} + CODEGEN_APP_PRIVATE_KEY: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} + # A repo with a tracker outside the built-in codegen and upstream-version pairs adds a with: block carrying a rules JSON array of head or head-prefix plus base. + # A repo that keeps the repository-wide auto-delete off and still wants bot branches gone sets delete-branch true in the same block. +``` + +A repo that needs either input appends the block to the `merge-bot` job. This is the shape for a repo with a `ha-version-bump/` tracker into `develop` that also wants its bot branches deleted: + +```yaml + with: + rules: '[{"head-prefix": "ha-version-bump/", "base": "develop"}]' + delete-branch: true +``` + +The task's inputs are `app-login` (default `ptr727-codegen[bot]`), `rules` (a JSON array of `{"head": "<exact>"}` or `{"head-prefix": "<prefix>"}` plus `"base"`, default `[]`), and `delete-branch` (default `false`). The merge method follows the base, `develop` squashes and `main` merges, so a rule carries none. An App pull request that matches no rule is annotated with a warning rather than merged, so a renamed tracker branch is visible in the run rather than silent. + +Two copies today filter Dependabot by ecosystem and semver tier before merging. [WORKFLOW.md D8.1][workflow-d8] says every Dependabot tier auto-merges and the required checks are the gate, so those two repos drop the filter on adoption unless the [Open Decisions](#open-decisions) below settle otherwise. + +## What a Pilot Proves + +The hub's own stub proves most of the mechanics on the first Dependabot pull request after the task lands on `develop`. That run shows the callee reading the caller's `github.event.*` under `pull_request_target`. It shows an explicit `secrets:` map reaching the callee and the App token minting inside one. It shows `permissions: {}` at the caller not failing the callee at startup, and `--squash` running on `develop`. A Dependabot pull request against `main` after promotion proves `--merge`, and a maintainer push to a bot branch proves the disable job. A hub feature branch cannot test itself, since under `pull_request_target` the callee resolves from the base branch, so the proof follows the merge rather than preceding it. + +Four things the hub cannot prove fall to the first downstream adopter. They are cross-repository resolution of the owner-scoped `uses:` reference, Dependabot bumping a `# <tag>` pin on a reusable workflow, and the `rules` input end to end on a repo with a tracker. The fourth is `merge-app` itself, since nothing opens App pull requests against the hub. A pilot records each of those as observed in its own audit report rather than assumed here. + +## Open Decisions + +- **`delete-branch` default.** `false` matches the hub's behavior, and seven repos opt in today. A fleet default of `true` is one edit to the task and removes seven `with:` blocks. The repository setting that protects `develop` from a promotion is unaffected either way, since a bot branch is never `develop`. +- **The Dependabot semver-major filter.** Two repos skip a nuget semver-major bump. Either it drops on adoption per D8.1, or the task grows a `skip-semver-major-ecosystems` input with a `dependabot/fetch-metadata` step run under the App token. Decide before those two repos adopt, everything else adopts unaffected. +- **A `requiredHubUses` audit contract.** The interface check today asserts the task filename token in the caller job. A field asserting the full owner-scoped form on a downstream copy and the `./` form on the hub is a small schema extension. It waits for the first adoption to show whether the token check misses anything. + +<!-- Repo --> + +[governance-hub-hosted-tooling]: ../GOVERNANCE.md#hub-hosted-tooling +[governance-workflow-yaml-conventions]: ../GOVERNANCE.md#workflow-yaml-conventions +[secrets]: ../spec/secrets.json +[workflow]: ../WORKFLOW.md +[workflow-d8]: ../WORKFLOW.md#d8---bots--automation +[workflow-reusable-task-parameter-contract]: ../WORKFLOW.md#reusable-task-parameter-contract +[todo]: ../TODO.md +[workflow-reuse-report]: ../reports/workflow-reuse.md diff --git a/docs/ssh-signing.md b/docs/ssh-signing.md index 72e7d194..3c314c2b 100644 --- a/docs/ssh-signing.md +++ b/docs/ssh-signing.md @@ -1,127 +1,127 @@ -# SSH Commit Signing - -This repo enforces signed commits on `main` and `develop` via branch protection. Use SSH signing, since one Ed25519 key serves both authentication (push) and signing. - -If you haven't generated a key and configured git yet, follow [host setup][host-setup] first. - -## Why SSH Signing - -- **One key for everything**. Same `id_ed25519` you use for `git push` also signs commits. No GPG keyring, no expirations to chase. -- **GitHub native**. GitHub treats authentication and signing keys independently but accepts the same public key for both, so register it twice on the SSH and GPG keys page. -- **Survives rotation cleanly**. When you rotate the key, update the `allowed_signers` file and old signatures still verify against the historical entry. - -## Configuration - -Per-user (host) git config, set once: - -```shell -git config --global gpg.format ssh -git config --global user.signingkey ~/.ssh/id_ed25519.pub -git config --global commit.gpgsign true -git config --global tag.gpgsign true -git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers -``` - -The `allowed_signers` file is what `git verify-commit` consults. Without it, signatures sign fine but verify as "unknown signer". Format: - -```text -you@example.com namespaces="git" ssh-ed25519 AAAA... your_public_key_contents_here -``` - -Build it from your existing public key: - -```shell -mkdir -p ~/.config/git -echo "$(git config user.email) namespaces=\"git\" $(cat ~/.ssh/id_ed25519.pub)" \ - >> ~/.config/git/allowed_signers -``` - -If you collaborate with others, append their entries to the same file, where each line maps an email to a public key. - -## Per-OS Setup Notes - -### Linux / WSL2 - -The SSH agent must be running for git to find the private key without prompting for the passphrase every commit. On systemd-based distros: - -```shell -systemctl --user enable --now ssh-agent.socket -ssh-add ~/.ssh/id_ed25519 -``` - -The agent socket lives at `$XDG_RUNTIME_DIR/ssh-agent.socket`. Make sure your shell exports `SSH_AUTH_SOCK` to point at it. Most distros do this in `/etc/X11/Xsession.d` or systemd user environment. - -For shells without systemd integration, fall back to ad-hoc agent in `~/.bashrc` or `~/.zshrc`: - -```shell -if [ -z "$SSH_AUTH_SOCK" ] || ! ssh-add -l >/dev/null 2>&1; then - eval "$(ssh-agent -s)" >/dev/null - ssh-add ~/.ssh/id_ed25519 2>/dev/null -fi -``` - -WSL2 specifically: WSL inherits no agent from Windows. Run `ssh-agent` inside WSL, and do not try to forward an agent from the Windows side. - -### macOS - -macOS has its own `ssh-agent` integrated with Keychain. To load your key once and have it persist across reboots: - -```shell -ssh-add --apple-use-keychain ~/.ssh/id_ed25519 -``` - -Add to `~/.ssh/config` so `ssh` and `git` use the Keychain-aware agent automatically: - -```sshconfig -Host github.com - HostName github.com - User git - IdentityFile ~/.ssh/id_ed25519 - IdentitiesOnly yes - UseKeychain yes - AddKeysToAgent yes -``` - -The Keychain prompt for the passphrase appears on first use after each reboot, and subsequent sessions are silent. - -### Windows (without WSL) - -Native Windows is **not supported** for the devcontainer setup in this repo. Use WSL2 instead. The reason: VS Code Dev Containers needs a Linux-like file system for the bind-mounts to behave consistently, and Docker Desktop's WSL2 backend is the supported path. - -If you must work on Windows directly without a devcontainer, OpenSSH for Windows can sign with `gpg.format=ssh`, but the bind-mounted devcontainer setup expects Linux/WSL2 paths. - -## Verify Signing - -The `-S` flag and `-c gpg.format=ssh` override are explicit so the verification works even before `commit.gpgsign` and `gpg.format` are set globally, which is useful when verifying a fresh setup mid-configuration. - -```shell -git -c gpg.format=ssh commit -S --allow-empty -m "verify-signing" -git log --show-signature -1 -``` - -Expected output includes `Good "git" signature for <your-email>`. If you see `error: gpg.ssh.allowedSignersFile needs to be configured` or `No signature`, walk back through the host setup. Most often `allowed_signers` is missing the entry, or the `user.signingkey` and `gpg.ssh.allowedSignersFile` configs aren't set yet. - -## Inside the Devcontainer - -The container picks up: - -- Your `~/.gitconfig` automatically (VS Code Dev Containers copies it on start). -- The `~/.ssh/id_ed25519.pub` and `~/.config/git/allowed_signers` files via bind-mount declared in `devcontainer.json`. -- The forwarded SSH agent socket from `SSH_AUTH_SOCK`, so signing happens with the host's loaded private key without the private key ever entering the container. - -If the container's `~/.ssh` directory exists with the wrong owner (root, surfaced by macOS bind-mount semantics), `gh auth login` writes to `~/.ssh/known_hosts` may fail. The `onCreateCommand` in `devcontainer.json` chowns the directory to `vscode` to fix this. See [devcontainer setup][devcontainer] for the rationale. - -## Troubleshooting - -**`gpg.ssh.allowedSignersFile needs to be configured`.** Set `git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers` and ensure the file exists. - -**`signing failed: no allowed signers`.** The `allowed_signers` file exists but doesn't contain a line matching `user.email` + a key. Re-run the `echo $(git config user.email) namespaces="git" $(cat ~/.ssh/id_ed25519.pub) >> ...` snippet. - -**Verifies on the host but not in the container.** The bind-mount source path differs. `${localEnv:HOME}` resolves on Linux/macOS hosts, and on Windows hosts (WSL2 backend) the `${localEnv:USERPROFILE}` fallback in `devcontainer.json` handles it. Check the actual mount with `mount | grep ssh` inside the container. - -**SSH agent says "could not open a connection".** The host's agent isn't running. Linux: `systemctl --user start ssh-agent.socket`. macOS: open a new terminal so launchd starts the agent. - -<!-- Repo --> - -[devcontainer]: ./devcontainer.md -[host-setup]: ./host-setup.md +# SSH Commit Signing + +This repo enforces signed commits on `main` and `develop` via branch protection. Use SSH signing, since one Ed25519 key serves both authentication (push) and signing. + +If you haven't generated a key and configured git yet, follow [host setup][host-setup] first. + +## Why SSH Signing + +- **One key for everything**. Same `id_ed25519` you use for `git push` also signs commits. No GPG keyring, no expirations to chase. +- **GitHub native**. GitHub treats authentication and signing keys independently but accepts the same public key for both, so register it twice on the SSH and GPG keys page. +- **Survives rotation cleanly**. When you rotate the key, update the `allowed_signers` file and old signatures still verify against the historical entry. + +## Configuration + +Per-user (host) git config, set once: + +```shell +git config --global gpg.format ssh +git config --global user.signingkey ~/.ssh/id_ed25519.pub +git config --global commit.gpgsign true +git config --global tag.gpgsign true +git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers +``` + +The `allowed_signers` file is what `git verify-commit` consults. Without it, signatures sign fine but verify as "unknown signer". Format: + +```text +you@example.com namespaces="git" ssh-ed25519 AAAA... your_public_key_contents_here +``` + +Build it from your existing public key: + +```shell +mkdir -p ~/.config/git +echo "$(git config user.email) namespaces=\"git\" $(cat ~/.ssh/id_ed25519.pub)" \ + >> ~/.config/git/allowed_signers +``` + +If you collaborate with others, append their entries to the same file, where each line maps an email to a public key. + +## Per-OS Setup Notes + +### Linux / WSL2 + +The SSH agent must be running for git to find the private key without prompting for the passphrase every commit. On systemd-based distros: + +```shell +systemctl --user enable --now ssh-agent.socket +ssh-add ~/.ssh/id_ed25519 +``` + +The agent socket lives at `$XDG_RUNTIME_DIR/ssh-agent.socket`. Make sure your shell exports `SSH_AUTH_SOCK` to point at it. Most distros do this in `/etc/X11/Xsession.d` or systemd user environment. + +For shells without systemd integration, fall back to ad-hoc agent in `~/.bashrc` or `~/.zshrc`: + +```shell +if [ -z "$SSH_AUTH_SOCK" ] || ! ssh-add -l >/dev/null 2>&1; then + eval "$(ssh-agent -s)" >/dev/null + ssh-add ~/.ssh/id_ed25519 2>/dev/null +fi +``` + +WSL2 specifically: WSL inherits no agent from Windows. Run `ssh-agent` inside WSL, and do not try to forward an agent from the Windows side. + +### macOS + +macOS has its own `ssh-agent` integrated with Keychain. To load your key once and have it persist across reboots: + +```shell +ssh-add --apple-use-keychain ~/.ssh/id_ed25519 +``` + +Add to `~/.ssh/config` so `ssh` and `git` use the Keychain-aware agent automatically: + +```sshconfig +Host github.com + HostName github.com + User git + IdentityFile ~/.ssh/id_ed25519 + IdentitiesOnly yes + UseKeychain yes + AddKeysToAgent yes +``` + +The Keychain prompt for the passphrase appears on first use after each reboot, and subsequent sessions are silent. + +### Windows (without WSL) + +Native Windows is **not supported** for the devcontainer setup in this repo. Use WSL2 instead. The reason: VS Code Dev Containers needs a Linux-like file system for the bind-mounts to behave consistently, and Docker Desktop's WSL2 backend is the supported path. + +If you must work on Windows directly without a devcontainer, OpenSSH for Windows can sign with `gpg.format=ssh`, but the bind-mounted devcontainer setup expects Linux/WSL2 paths. + +## Verify Signing + +The `-S` flag and `-c gpg.format=ssh` override are explicit so the verification works even before `commit.gpgsign` and `gpg.format` are set globally, which is useful when verifying a fresh setup mid-configuration. + +```shell +git -c gpg.format=ssh commit -S --allow-empty -m "verify-signing" +git log --show-signature -1 +``` + +Expected output includes `Good "git" signature for <your-email>`. If you see `error: gpg.ssh.allowedSignersFile needs to be configured` or `No signature`, walk back through the host setup. Most often `allowed_signers` is missing the entry, or the `user.signingkey` and `gpg.ssh.allowedSignersFile` configs aren't set yet. + +## Inside the Devcontainer + +The container picks up: + +- Your `~/.gitconfig` automatically (VS Code Dev Containers copies it on start). +- The `~/.ssh/id_ed25519.pub` and `~/.config/git/allowed_signers` files via bind-mount declared in `devcontainer.json`. +- The forwarded SSH agent socket from `SSH_AUTH_SOCK`, so signing happens with the host's loaded private key without the private key ever entering the container. + +If the container's `~/.ssh` directory exists with the wrong owner (root, surfaced by macOS bind-mount semantics), `gh auth login` writes to `~/.ssh/known_hosts` may fail. The `onCreateCommand` in `devcontainer.json` chowns the directory to `vscode` to fix this. See [devcontainer setup][devcontainer] for the rationale. + +## Troubleshooting + +**`gpg.ssh.allowedSignersFile needs to be configured`.** Set `git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers` and ensure the file exists. + +**`signing failed: no allowed signers`.** The `allowed_signers` file exists but doesn't contain a line matching `user.email` + a key. Re-run the `echo $(git config user.email) namespaces="git" $(cat ~/.ssh/id_ed25519.pub) >> ...` snippet. + +**Verifies on the host but not in the container.** The bind-mount source path differs. `${localEnv:HOME}` resolves on Linux/macOS hosts, and on Windows hosts (WSL2 backend) the `${localEnv:USERPROFILE}` fallback in `devcontainer.json` handles it. Check the actual mount with `mount | grep ssh` inside the container. + +**SSH agent says "could not open a connection".** The host's agent isn't running. Linux: `systemctl --user start ssh-agent.socket`. macOS: open a new terminal so launchd starts the agent. + +<!-- Repo --> + +[devcontainer]: ./devcontainer.md +[host-setup]: ./host-setup.md diff --git a/docs/token-cost.md b/docs/token-cost.md index 405698b8..22dd7e88 100644 --- a/docs/token-cost.md +++ b/docs/token-cost.md @@ -1,104 +1,104 @@ -# Agent Token Cost: Measurement and Response (Hub-Only) - -Why the [`AGENTS.md`][agents] "Context and Delegation Discipline" rules exist, what they were measured against, and what remains open. This doc is **hub-only** and records a measurement, so it is not carried downstream. Without it those rules read as unmotivated preference and a later agent deletes them. - -Measured over 220 Claude Code transcripts and 383 pull requests, June to July 2026. Every figure comes from `message.usage` records de-duplicated by request id, or from the GitHub GraphQL API. Costs use the first-party API rate: Opus $5 in and $25 out per million tokens, cache read at 0.1x, cache write at 1.25x on the 5-minute TTL and 2x on the 1-hour. - -## The Equation - -```text -cost ~= 0.1 x (requests) x (average context) -``` - -A session's context grows monotonically and every request re-reads all of it at the cache-read rate, so a token added early is paid for again on every request that follows. Both factors were large: 18,254 requests at an average of about 386,000 tokens of context, for **7.05 billion prompt tokens** and roughly **$5,086**. - -| Component | Cost | Share | -| --- | ---: | ---: | -| Cache reads | $3,466 | 68.1% | -| Cache writes | $1,083 | 21.3% | -| Output | $528 | 10.4% | -| Fresh input | $9 | 0.2% | - -**Output verbosity is not the lever.** Output was 0.30% of prompt volume and 10.4% of cost, so instructing an agent to write less prose targets a tenth of the bill at best. The cache is also working correctly at a 98.4% read share. The problem is the volume being re-read, not the hit rate. - -## The Root Cause: Sessions Outliving Their Task - -| Percentile | Context per request | -| --- | ---: | -| p50 | 418,063 | -| p75 | 671,139 | -| p90 | 861,035 | -| p99 | 987,530 | - -Sessions ran repeatedly into the 1M context ceiling, and **55% of all spend sat on requests carrying more than 600k**. The cause was not one long task. The largest session ran **88 branches over 11 days in one context**, 4,151 requests and 2.12 billion prompt tokens, with 165 branch switches. Context at its first branch switch was 71,045 tokens, so by the 88th task every request was carrying the residue of the previous 87. That single session cost about $1,060 in cache reads. - -A contiguous single-branch piece of work is small by comparison: median 16 requests, p90 65, mean 36. The waste was never within a deliverable, it was across unrelated ones. - -### Counterfactual - -Resetting context at each branch switch, holding request count and within-task growth constant, takes 6.93 billion prompt tokens to 1.24 billion. Sensitivity to what a fresh session must re-read: - -| Re-read penalty per task | Cache-read cost | Saving | -| ---: | ---: | ---: | -| 0 | $622 | 82% | -| 25,000 tokens | $815 | 76% | -| 50,000 | $1,007 | 71% | -| 100,000 | $1,393 | 60% | -| 150,000 | $1,778 | 49% | - -Even at 150,000 tokens, over four times the observed 34,000-token session baseline, the saving is 49%. This is why "one deliverable, one session" is a rule rather than a suggestion. - -## Attribution - -Two measured attributions. Both **overlap** with the counterfactual above rather than adding to it, because both are driven by how many requests remain after a token enters context. - -### Re-reading the governance set: $706, 20% of the cache-read bill - -| File | Reads | Cost | -| --- | ---: | ---: | -| `AGENTS.md` (pre-split, 87 KB) | 216 | $273 | -| `WORKFLOW.md` | 245 | $253 | -| `README.md` | 125 | $96 | -| `.github/copilot-instructions.md` | 48 | $36 | -| `CODESTYLE.md` | 56 | $32 | -| `AUDIT.md` | 27 | $17 | - -Median 199 requests remained in the session after such a read, p90 6,005, and only 36.9% of all reads used a range. `AGENTS.md` was **not** auto-loaded by any harness (the session baseline was 33,000 to 37,000 tokens with none of its text present), so the whole cost came from explicit whole-file reads. - -The rule text could not simply be cut: 16 of 18 sections were declared `fidelity: verbatim` and carried byte-identically to the fleet. So the file was split instead (`AGENTS.md` from 87,457 to 7,789 bytes as a router, with the rule text in [`GOVERNANCE.md`][governance]), and reading one section now costs about 3,200 bytes against 87,457. - -### GitHub orchestration: $938, 27% of the cache-read bill - -| Category | Requests | Cost | Avg result | -| --- | ---: | ---: | ---: | -| Copilot review polling | 2,848 | $634 | 574 B | -| Other `gh` | 590 | $106 | 1,558 B | -| Merge | 422 | $82 | 576 B | -| CI status polling | 463 | $80 | 545 B | -| Create pull request | 190 | $36 | 384 B | - -This inverts the obvious fix. The `gh` output was already tiny at 574 bytes average, while the turns carrying it were billed against about 417,000 tokens of context, a ratio near 960 to 1. **100% of gh-issuing turns contained exactly one gh call**, so there was no batching at all. Compressing gh output saves nothing and removing turns saves everything, which is what [`scripts/pr_review.py`][pr-review] does. - -## Review Round-Trips - -383 pull requests, 1,047 Copilot findings, 783 review rounds: mean 2.42 rounds per reviewed pull request, median 2, **maximum 15**. 54% needed two or more. One case, a single-file change, took 14 rounds. - -1,003 of the 1,047 findings were resolved, so the reviewer was finding real defects. This is rework to pre-empt, not noise to suppress. Findings by file type were 39% Markdown, 21% Python, 19% workflow YAML. An audit put roughly half in categories a deterministic check can catch before a push, which is what [`scripts/prose_lint.py`][prose-lint] and [`scripts/repo_gate.py`][repo-gate] cover. - -## What Is Not Settled - -- **Model and effort tiering is unquantified.** The mix was 17,851 Opus requests, 390 Haiku, and zero Sonnet, with a global high effort setting, so the headroom is obvious. The saving is not: establishing it needs deliberate paired runs, and no number is claimed here. -- **Fresh-context self-review is unproven.** An estimate put 44% of findings within reach of a reviewer reading the diff against the docs, but that was an estimate, and a same-model no-context review was tried and found far less than Copilot did. Removing context does not remove shared priors. Treat it as an experiment, and give any retest the finding taxonomy rather than a generic instruction to be adversarial. -- **The semicolon and dash rules are warn-first, and the charset and duplicate-word rules gate, being clean tree-wide.** Compare their hits against the next batch of review findings before enforcing them, and do not claim the mechanical share until that comparison exists. - -## Re-measuring - -The measurement is reproducible from the transcripts in about a minute, so the effect of these changes is checkable without instrumentation. Re-run the aggregation over `message.usage` records and compare the average context per request and the branch count per session against the figures above. - -<!-- Internal --> - -[agents]: ../AGENTS.md -[governance]: ../GOVERNANCE.md -[pr-review]: ../scripts/pr_review.py -[prose-lint]: ../scripts/prose_lint.py -[repo-gate]: ../scripts/repo_gate.py +# Agent Token Cost: Measurement and Response (Hub-Only) + +Why the [`AGENTS.md`][agents] "Context and Delegation Discipline" rules exist, what they were measured against, and what remains open. This doc is **hub-only** and records a measurement, so it is not carried downstream. Without it those rules read as unmotivated preference and a later agent deletes them. + +Measured over 220 Claude Code transcripts and 383 pull requests, June to July 2026. Every figure comes from `message.usage` records de-duplicated by request id, or from the GitHub GraphQL API. Costs use the first-party API rate: Opus $5 in and $25 out per million tokens, cache read at 0.1x, cache write at 1.25x on the 5-minute TTL and 2x on the 1-hour. + +## The Equation + +```text +cost ~= 0.1 x (requests) x (average context) +``` + +A session's context grows monotonically and every request re-reads all of it at the cache-read rate, so a token added early is paid for again on every request that follows. Both factors were large: 18,254 requests at an average of about 386,000 tokens of context, for **7.05 billion prompt tokens** and roughly **$5,086**. + +| Component | Cost | Share | +| --- | ---: | ---: | +| Cache reads | $3,466 | 68.1% | +| Cache writes | $1,083 | 21.3% | +| Output | $528 | 10.4% | +| Fresh input | $9 | 0.2% | + +**Output verbosity is not the lever.** Output was 0.30% of prompt volume and 10.4% of cost, so instructing an agent to write less prose targets a tenth of the bill at best. The cache is also working correctly at a 98.4% read share. The problem is the volume being re-read, not the hit rate. + +## The Root Cause: Sessions Outliving Their Task + +| Percentile | Context per request | +| --- | ---: | +| p50 | 418,063 | +| p75 | 671,139 | +| p90 | 861,035 | +| p99 | 987,530 | + +Sessions ran repeatedly into the 1M context ceiling, and **55% of all spend sat on requests carrying more than 600k**. The cause was not one long task. The largest session ran **88 branches over 11 days in one context**, 4,151 requests and 2.12 billion prompt tokens, with 165 branch switches. Context at its first branch switch was 71,045 tokens, so by the 88th task every request was carrying the residue of the previous 87. That single session cost about $1,060 in cache reads. + +A contiguous single-branch piece of work is small by comparison: median 16 requests, p90 65, mean 36. The waste was never within a deliverable, it was across unrelated ones. + +### Counterfactual + +Resetting context at each branch switch, holding request count and within-task growth constant, takes 6.93 billion prompt tokens to 1.24 billion. Sensitivity to what a fresh session must re-read: + +| Re-read penalty per task | Cache-read cost | Saving | +| ---: | ---: | ---: | +| 0 | $622 | 82% | +| 25,000 tokens | $815 | 76% | +| 50,000 | $1,007 | 71% | +| 100,000 | $1,393 | 60% | +| 150,000 | $1,778 | 49% | + +Even at 150,000 tokens, over four times the observed 34,000-token session baseline, the saving is 49%. This is why "one deliverable, one session" is a rule rather than a suggestion. + +## Attribution + +Two measured attributions. Both **overlap** with the counterfactual above rather than adding to it, because both are driven by how many requests remain after a token enters context. + +### Re-reading the governance set: $706, 20% of the cache-read bill + +| File | Reads | Cost | +| --- | ---: | ---: | +| `AGENTS.md` (pre-split, 87 KB) | 216 | $273 | +| `WORKFLOW.md` | 245 | $253 | +| `README.md` | 125 | $96 | +| `.github/copilot-instructions.md` | 48 | $36 | +| `CODESTYLE.md` | 56 | $32 | +| `AUDIT.md` | 27 | $17 | + +Median 199 requests remained in the session after such a read, p90 6,005, and only 36.9% of all reads used a range. `AGENTS.md` was **not** auto-loaded by any harness (the session baseline was 33,000 to 37,000 tokens with none of its text present), so the whole cost came from explicit whole-file reads. + +The rule text could not simply be cut: 16 of 18 sections were declared `fidelity: verbatim` and carried byte-identically to the fleet. So the file was split instead (`AGENTS.md` from 87,457 to 7,789 bytes as a router, with the rule text in [`GOVERNANCE.md`][governance]), and reading one section now costs about 3,200 bytes against 87,457. + +### GitHub orchestration: $938, 27% of the cache-read bill + +| Category | Requests | Cost | Avg result | +| --- | ---: | ---: | ---: | +| Copilot review polling | 2,848 | $634 | 574 B | +| Other `gh` | 590 | $106 | 1,558 B | +| Merge | 422 | $82 | 576 B | +| CI status polling | 463 | $80 | 545 B | +| Create pull request | 190 | $36 | 384 B | + +This inverts the obvious fix. The `gh` output was already tiny at 574 bytes average, while the turns carrying it were billed against about 417,000 tokens of context, a ratio near 960 to 1. **100% of gh-issuing turns contained exactly one gh call**, so there was no batching at all. Compressing gh output saves nothing and removing turns saves everything, which is what [`scripts/pr_review.py`][pr-review] does. + +## Review Round-Trips + +383 pull requests, 1,047 Copilot findings, 783 review rounds: mean 2.42 rounds per reviewed pull request, median 2, **maximum 15**. 54% needed two or more. One case, a single-file change, took 14 rounds. + +1,003 of the 1,047 findings were resolved, so the reviewer was finding real defects. This is rework to pre-empt, not noise to suppress. Findings by file type were 39% Markdown, 21% Python, 19% workflow YAML. An audit put roughly half in categories a deterministic check can catch before a push, which is what [`scripts/prose_lint.py`][prose-lint] and [`scripts/repo_gate.py`][repo-gate] cover. + +## What Is Not Settled + +- **Model and effort tiering is unquantified.** The mix was 17,851 Opus requests, 390 Haiku, and zero Sonnet, with a global high effort setting, so the headroom is obvious. The saving is not: establishing it needs deliberate paired runs, and no number is claimed here. +- **Fresh-context self-review is unproven.** An estimate put 44% of findings within reach of a reviewer reading the diff against the docs, but that was an estimate, and a same-model no-context review was tried and found far less than Copilot did. Removing context does not remove shared priors. Treat it as an experiment, and give any retest the finding taxonomy rather than a generic instruction to be adversarial. +- **The semicolon and dash rules are warn-first, and the charset and duplicate-word rules gate, being clean tree-wide.** Compare their hits against the next batch of review findings before enforcing them, and do not claim the mechanical share until that comparison exists. + +## Re-measuring + +The measurement is reproducible from the transcripts in about a minute, so the effect of these changes is checkable without instrumentation. Re-run the aggregation over `message.usage` records and compare the average context per request and the branch count per session against the figures above. + +<!-- Internal --> + +[agents]: ../AGENTS.md +[governance]: ../GOVERNANCE.md +[pr-review]: ../scripts/pr_review.py +[prose-lint]: ../scripts/prose_lint.py +[repo-gate]: ../scripts/repo_gate.py diff --git a/host-setup/README.md b/host-setup/README.md index 6b5e8a46..d7fcd747 100644 --- a/host-setup/README.md +++ b/host-setup/README.md @@ -1,94 +1,94 @@ -# Host Setup - -What a machine needs before it can be worked in, and the tooling that puts it there. [`docs/host-setup.md`][host-setup] is the contract, meaning which tools a host must provide and why each floor exists. This directory is how a host comes to satisfy it. - -## What Is Here - -- [`bootstrap.sh`][bootstrap] stands a Debian or Ubuntu host up from nothing. It is the one file fetched on its own, because a host with no git and no checkout is what it exists to fix. It fetches this repository and runs the tooling from that tree. -- [`bootstrap.ps1`][bootstrap-ps1] does the same for native Windows. It runs under Windows PowerShell 5.1, the version every fresh Windows host guarantees, and hands off to PowerShell 7 once it has found or installed it, since every script it drives requires that version. -- [`linux/`][linux] holds the tooling itself, for Debian and Ubuntu based hosts, Proxmox and WSL included. `install-tools.sh` installs and upgrades the host tools, `upgrade-host.sh` upgrades the packages of the current release or moves to the next one, `setup-github.sh` configures the SSH key, git, and commit signing, and `install-skills.sh` drives the hub's skills installer from the same tree. -- [`windows/`][windows] holds the tooling for native Windows, through `winget` and PowerShell 7. `install-tools.ps1` installs and upgrades the host tools, `upgrade-host.ps1` upgrades the winget packages and updates the WSL platform, `setup-github.ps1` configures the SSH key, git, and commit signing, `setup-wsl.ps1` installs a WSL distribution and reports the Docker Desktop integration, and `install-skills.ps1` drives the hub's skills installer from the same tree. -- [`agent-safety/`][agent-safety] holds the write-safety guards, deployed per machine and per account. - -## Standing a Host Up - -Three lines, on a Debian or Ubuntu host that has nothing: - -```shell -sudo apt-get update && sudo apt-get install -y curl ca-certificates tar -curl -fsSLo bootstrap.sh https://raw.githubusercontent.com/ptr727/ProjectTemplate/main/host-setup/bootstrap.sh -bash bootstrap.sh -``` - -Downloaded and run rather than piped into a shell, for three reasons. Standard input stays a terminal, so the menu can ask. The file is readable before it is executed as root, which matters more here than anywhere else in this repository. And a re-run on a console reached over IPMI costs one line rather than a re-paste. - -Piping it is still detected: with no action and no terminal it reports rather than guessing, and prints the three lines above as the remedy. - -```shell -./bootstrap.sh --report # what each tool would do, changing nothing -./bootstrap.sh --host --yes # packages, tools, git and GitHub, unattended -./bootstrap.sh --ref develop --report # run the tooling as it is on develop -./bootstrap.sh --help # every action and option -``` - -Three lines as well, on a native Windows host that has nothing, pasted into a stock `powershell.exe` console: - -```powershell -[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 -Invoke-WebRequest -UseBasicParsing -Uri https://raw.githubusercontent.com/ptr727/ProjectTemplate/main/host-setup/bootstrap.ps1 -OutFile bootstrap.ps1 -powershell -ExecutionPolicy Bypass -File bootstrap.ps1 -``` - -The first line is the Windows counterpart of installing `curl`: a fresh console's default TLS floor can predate 1.2, which every host this fetches from requires. The third runs under the `powershell.exe` a fresh host guarantees rather than `pwsh`, since `bootstrap.ps1` finds or installs PowerShell 7 itself and hands the rest of the run to it. `-ExecutionPolicy Bypass` clears both the default `Restricted` policy and the mark of the web `Invoke-WebRequest` leaves on the file, in one flag. - -```powershell -.\bootstrap.ps1 -Report # what each tool would do, changing nothing -.\bootstrap.ps1 -Host -Yes # packages, tools, git and GitHub, unattended -.\bootstrap.ps1 -Ref develop -Report # run the tooling as it is on develop -.\bootstrap.ps1 -Help # every action and option -``` - -Each tool also runs on its own, on a host that already has a checkout: - -```shell -host-setup/linux/install-tools.sh # report -host-setup/linux/install-tools.sh --install -host-setup/linux/upgrade-host.sh --status -host-setup/linux/setup-github.sh --status -``` - -On native Windows, in PowerShell 7: - -```powershell -host-setup\windows\install-tools.ps1 # report -host-setup\windows\install-tools.ps1 -Install -host-setup\windows\upgrade-host.ps1 -Status -host-setup\windows\setup-github.ps1 -Status -host-setup\windows\setup-wsl.ps1 -Status -``` - -## Which Revision a Run Used - -`bootstrap.sh` resolves the ref it was given to the commit it names, prints that commit, and downloads that exact revision. A run therefore says which revision of the tooling it used, and a second run of the same ref cannot silently be a different tree. Where the resolve fails, which an unauthenticated rate limit can cause, the run says it cannot attribute itself and continues, since the download itself is unaffected. - -`--ref` takes a branch, a tag, a pull request ref, or a commit. It is not only for testing an unmerged change: [`AUDIT.md`][audit] and the sync procedure both tell an agent to fetch this repository immediately before reading it, and a loader that could only reach `main` could not support a repository testing a change before it promotes. - -## Three Rules This Directory Follows - -**Group by whichever axis has one member.** `agent-safety/` is one concern across three platforms, so it is a concern directory holding `install.sh`, `install.ps1` and `install.py`. `linux/` is three concerns on one platform, so it is a platform directory. Windows host tooling therefore sits at `windows/` rather than beside the Linux scripts, because the `winget` equivalent of `install-tools.sh` is a different program rather than a translation of one. It carries one registry record per tool where the Linux script carries four functions, since every Windows source is `winget` and the per-tool variation those functions exist for does not arise. `windows/` also carries a fourth script with no Linux peer, because WSL is a Windows-side concern. The loader is the same shape as `agent-safety/`, not as `linux/`/`windows/`: one concern, two platforms, so `bootstrap.ps1` sits beside `bootstrap.sh` at the top level rather than inside `windows/`. - -**Nothing here needs Python to stand a host up, and neither loader needs an interpreter to fetch what it drives.** [`docs/host-setup.md`][host-setup] carries that as part of the contract, with the reasoning. `bootstrap.sh` needs only `curl` and `tar`. `bootstrap.ps1` needs only `tar.exe`, which has shipped with Windows since 1803, and installs its one further dependency, `pwsh`, itself through `winget`. The one exception is the `install-skills` pair, which drives the Python installer at `scripts/skills_install.py` and runs last in a stand-up for exactly that reason: `install-tools` has provided the interpreter by then, and run alone on a host without one it stops and names the tools step as its prerequisite. Neither loader runs a gate as a closing step: [`scripts/host_gate.py`][host-gate] measures a host against the floors and is not called from here, and nothing here is called from it. A host set up by hand years ago is an ordinary host, so the gate reports what it is missing and running this tooling is a remedy a person chooses. The two are joined at code time instead, by [`scripts/tests/test_bootstrap.py`][test-bootstrap] asserting that every tool the spec requires is one this tooling can provide. - -**The scripts under `linux/` and `windows/` share no file, and the duplication is deliberate.** Each is independently fetchable and runnable on its own, which is the property that lets a host with no checkout use one without the others. A shared helper file would take that away: the moment one script sources a sibling, fetching it alone yields a script that dies on a missing file. What is duplicated is about thirty lines each of logging, the dry-run wrapper, the confirmation prompt, and a temporary directory, and those copies are identical rather than merely similar. Do not factor them out. `bootstrap.ps1` now exercises the same fetchability argument `bootstrap.sh` always has, rather than merely being written to allow for it. The `install-skills` pair is the one recorded exception to independent fetchability: it drives `scripts/skills_install.py` at the tree root, because the skills content lives in the tree, so a copy fetched alone has nothing to install and the property cannot apply to it. - -<!-- Repo --> - -[agent-safety]: ./agent-safety/ -[audit]: ../AUDIT.md -[bootstrap]: ./bootstrap.sh -[bootstrap-ps1]: ./bootstrap.ps1 -[host-gate]: ../scripts/host_gate.py -[host-setup]: ../docs/host-setup.md -[linux]: ./linux/ -[test-bootstrap]: ../scripts/tests/test_bootstrap.py -[windows]: ./windows/ +# Host Setup + +What a machine needs before it can be worked in, and the tooling that puts it there. [`docs/host-setup.md`][host-setup] is the contract, meaning which tools a host must provide and why each floor exists. This directory is how a host comes to satisfy it. + +## What Is Here + +- [`bootstrap.sh`][bootstrap] stands a Debian or Ubuntu host up from nothing. It is the one file fetched on its own, because a host with no git and no checkout is what it exists to fix. It fetches this repository and runs the tooling from that tree. +- [`bootstrap.ps1`][bootstrap-ps1] does the same for native Windows. It runs under Windows PowerShell 5.1, the version every fresh Windows host guarantees, and hands off to PowerShell 7 once it has found or installed it, since every script it drives requires that version. +- [`linux/`][linux] holds the tooling itself, for Debian and Ubuntu based hosts, Proxmox and WSL included. `install-tools.sh` installs and upgrades the host tools, `upgrade-host.sh` upgrades the packages of the current release or moves to the next one, `setup-github.sh` configures the SSH key, git, and commit signing, and `install-skills.sh` drives the hub's skills installer from the same tree. +- [`windows/`][windows] holds the tooling for native Windows, through `winget` and PowerShell 7. `install-tools.ps1` installs and upgrades the host tools, `upgrade-host.ps1` upgrades the winget packages and updates the WSL platform, `setup-github.ps1` configures the SSH key, git, and commit signing, `setup-wsl.ps1` installs a WSL distribution and reports the Docker Desktop integration, and `install-skills.ps1` drives the hub's skills installer from the same tree. +- [`agent-safety/`][agent-safety] holds the write-safety guards, deployed per machine and per account. + +## Standing a Host Up + +Three lines, on a Debian or Ubuntu host that has nothing: + +```shell +sudo apt-get update && sudo apt-get install -y curl ca-certificates tar +curl -fsSLo bootstrap.sh https://raw.githubusercontent.com/ptr727/ProjectTemplate/main/host-setup/bootstrap.sh +bash bootstrap.sh +``` + +Downloaded and run rather than piped into a shell, for three reasons. Standard input stays a terminal, so the menu can ask. The file is readable before it is executed as root, which matters more here than anywhere else in this repository. And a re-run on a console reached over IPMI costs one line rather than a re-paste. + +Piping it is still detected: with no action and no terminal it reports rather than guessing, and prints the three lines above as the remedy. + +```shell +./bootstrap.sh --report # what each tool would do, changing nothing +./bootstrap.sh --host --yes # packages, tools, git and GitHub, unattended +./bootstrap.sh --ref develop --report # run the tooling as it is on develop +./bootstrap.sh --help # every action and option +``` + +Three lines as well, on a native Windows host that has nothing, pasted into a stock `powershell.exe` console: + +```powershell +[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 +Invoke-WebRequest -UseBasicParsing -Uri https://raw.githubusercontent.com/ptr727/ProjectTemplate/main/host-setup/bootstrap.ps1 -OutFile bootstrap.ps1 +powershell -ExecutionPolicy Bypass -File bootstrap.ps1 +``` + +The first line is the Windows counterpart of installing `curl`: a fresh console's default TLS floor can predate 1.2, which every host this fetches from requires. The third runs under the `powershell.exe` a fresh host guarantees rather than `pwsh`, since `bootstrap.ps1` finds or installs PowerShell 7 itself and hands the rest of the run to it. `-ExecutionPolicy Bypass` clears both the default `Restricted` policy and the mark of the web `Invoke-WebRequest` leaves on the file, in one flag. + +```powershell +.\bootstrap.ps1 -Report # what each tool would do, changing nothing +.\bootstrap.ps1 -Host -Yes # packages, tools, git and GitHub, unattended +.\bootstrap.ps1 -Ref develop -Report # run the tooling as it is on develop +.\bootstrap.ps1 -Help # every action and option +``` + +Each tool also runs on its own, on a host that already has a checkout: + +```shell +host-setup/linux/install-tools.sh # report +host-setup/linux/install-tools.sh --install +host-setup/linux/upgrade-host.sh --status +host-setup/linux/setup-github.sh --status +``` + +On native Windows, in PowerShell 7: + +```powershell +host-setup\windows\install-tools.ps1 # report +host-setup\windows\install-tools.ps1 -Install +host-setup\windows\upgrade-host.ps1 -Status +host-setup\windows\setup-github.ps1 -Status +host-setup\windows\setup-wsl.ps1 -Status +``` + +## Which Revision a Run Used + +`bootstrap.sh` resolves the ref it was given to the commit it names, prints that commit, and downloads that exact revision. A run therefore says which revision of the tooling it used, and a second run of the same ref cannot silently be a different tree. Where the resolve fails, which an unauthenticated rate limit can cause, the run says it cannot attribute itself and continues, since the download itself is unaffected. + +`--ref` takes a branch, a tag, a pull request ref, or a commit. It is not only for testing an unmerged change: [`AUDIT.md`][audit] and the sync procedure both tell an agent to fetch this repository immediately before reading it, and a loader that could only reach `main` could not support a repository testing a change before it promotes. + +## Three Rules This Directory Follows + +**Group by whichever axis has one member.** `agent-safety/` is one concern across three platforms, so it is a concern directory holding `install.sh`, `install.ps1` and `install.py`. `linux/` is three concerns on one platform, so it is a platform directory. Windows host tooling therefore sits at `windows/` rather than beside the Linux scripts, because the `winget` equivalent of `install-tools.sh` is a different program rather than a translation of one. It carries one registry record per tool where the Linux script carries four functions, since every Windows source is `winget` and the per-tool variation those functions exist for does not arise. `windows/` also carries a fourth script with no Linux peer, because WSL is a Windows-side concern. The loader is the same shape as `agent-safety/`, not as `linux/`/`windows/`: one concern, two platforms, so `bootstrap.ps1` sits beside `bootstrap.sh` at the top level rather than inside `windows/`. + +**Nothing here needs Python to stand a host up, and neither loader needs an interpreter to fetch what it drives.** [`docs/host-setup.md`][host-setup] carries that as part of the contract, with the reasoning. `bootstrap.sh` needs only `curl` and `tar`. `bootstrap.ps1` needs only `tar.exe`, which has shipped with Windows since 1803, and installs its one further dependency, `pwsh`, itself through `winget`. The one exception is the `install-skills` pair, which drives the Python installer at `scripts/skills_install.py` and runs last in a stand-up for exactly that reason: `install-tools` has provided the interpreter by then, and run alone on a host without one it stops and names the tools step as its prerequisite. Neither loader runs a gate as a closing step: [`scripts/host_gate.py`][host-gate] measures a host against the floors and is not called from here, and nothing here is called from it. A host set up by hand years ago is an ordinary host, so the gate reports what it is missing and running this tooling is a remedy a person chooses. The two are joined at code time instead, by [`scripts/tests/test_bootstrap.py`][test-bootstrap] asserting that every tool the spec requires is one this tooling can provide. + +**The scripts under `linux/` and `windows/` share no file, and the duplication is deliberate.** Each is independently fetchable and runnable on its own, which is the property that lets a host with no checkout use one without the others. A shared helper file would take that away: the moment one script sources a sibling, fetching it alone yields a script that dies on a missing file. What is duplicated is about thirty lines each of logging, the dry-run wrapper, the confirmation prompt, and a temporary directory, and those copies are identical rather than merely similar. Do not factor them out. `bootstrap.ps1` now exercises the same fetchability argument `bootstrap.sh` always has, rather than merely being written to allow for it. The `install-skills` pair is the one recorded exception to independent fetchability: it drives `scripts/skills_install.py` at the tree root, because the skills content lives in the tree, so a copy fetched alone has nothing to install and the property cannot apply to it. + +<!-- Repo --> + +[agent-safety]: ./agent-safety/ +[audit]: ../AUDIT.md +[bootstrap]: ./bootstrap.sh +[bootstrap-ps1]: ./bootstrap.ps1 +[host-gate]: ../scripts/host_gate.py +[host-setup]: ../docs/host-setup.md +[linux]: ./linux/ +[test-bootstrap]: ../scripts/tests/test_bootstrap.py +[windows]: ./windows/ diff --git a/host-setup/agent-safety/.markdownlint-cli2.jsonc b/host-setup/agent-safety/.markdownlint-cli2.jsonc index 4e090637..19e51c7e 100644 --- a/host-setup/agent-safety/.markdownlint-cli2.jsonc +++ b/host-setup/agent-safety/.markdownlint-cli2.jsonc @@ -1,9 +1,9 @@ -{ - // The claude-md-safety.md fragment is appended by the installer into ~/.claude/CLAUDE.md. - // That file already has its own H1, so the fragment intentionally opens at H2. - // MD041 requires the first line to be a top-level heading, which an appended snippet is not. - // This nested config affects only this directory. - "config": { - "MD041": false - } -} +{ + // The claude-md-safety.md fragment is appended by the installer into ~/.claude/CLAUDE.md. + // That file already has its own H1, so the fragment intentionally opens at H2. + // MD041 requires the first line to be a top-level heading, which an appended snippet is not. + // This nested config affects only this directory. + "config": { + "MD041": false + } +} diff --git a/host-setup/agent-safety/README.md b/host-setup/agent-safety/README.md index 002aa729..52719a5f 100644 --- a/host-setup/agent-safety/README.md +++ b/host-setup/agent-safety/README.md @@ -1,103 +1,103 @@ -# Agent Write-Safety Kit - -Per-machine, user-account-scoped guards against an agent making a mis-targeted GitHub **write** under the maintainer's identity, or a **git operation that bypasses a branch rule or overrides a required check**, meaning a push, force-push, or delete that an active branch rule forbids, or an override flag (`--admin` past the server-side merge gate, `--no-verify` past the local git hooks). Deploy it as the **first thing on any new system** where Claude Code runs with the `gh` credentials logged in (Linux, WSL, macOS, Windows). - -## What It Installs - -Into `~/.claude/` (or `%USERPROFILE%\.claude\` on Windows): - -- **`hooks/gh-write-guard.py`**: a PreToolUse hook that denies two classes of dangerous action. First, the GitHub **write** footguns behind the cross-repo comment incident: a state-changing `gh` call whose output is discarded, a GraphQL mutation passing a **literal** node id instead of a `$variable`, and a `gh` write whose explicit target is under an owner other than the checkout origin's. Sibling repositories under the same owner are allowed, since the harm this guards is reaching a stranger's repository rather than working across one maintainer's own fleet, and a different owner is allowed only when the maintainer names it in `GH_WRITE_GUARD_ALLOW` (an `owner/repo` list, where `owner/*` grants a whole owner). That variable is read from the environment the session was launched with, which is the one channel an agent cannot set for itself: a hook runs as its own process, so an inline `VAR=x cmd` prefix or an `export` inside a Bash call never reaches it. Second, a **git operation that bypasses a repository protection**. The branch-rule cases (a direct push to a branch that requires a pull request, a force-push where history is protected, a branch delete where deletion is blocked) are judged against the branch's **live** rules, so a code-style `develop` is denied while a config-style `develop` is allowed with no per-repo configuration. A push to a protected-default branch fails closed when its rules cannot be determined, either because the API is unreachable or because the checkout's origin cannot be resolved to query them. The explicit-bypass flags are denied **unconditionally**, since the flag is itself the bypass and needs no branch query: `gh pr merge --admin` overrides the server-side merge gate, and `git commit`/`git push --no-verify` skips the local git hooks. Reads and everything else pass through. It fires even in autonomous / bypass-permissions sessions, which is how the incident happened. -- **Two marker-delimited blocks of host-wide content in `CLAUDE.md`**, loaded into every session on the machine (including ad-hoc work outside any project), installed and updated independently so one can change without rewriting the other. - - The `agent-safety` block carries restrictions alone, so nothing in it can widen a permission. `Repository Boundaries and Write Safety` mirrors the committed `GOVERNANCE.md` section of the same name, carrying as behavioral guidance its write rules and its rules that a task works in its own checkout and isolates into its own worktree before its first file edit. `Data in Agent-Authored Text` mirrors `GOVERNANCE.md` "Representative Data in Agent-Authored Text", so an agent illustrates with constructed data rather than data observed on the machine. `Authorization Scope and Memory Hygiene` bounds how a granted permission is recorded and read. Each mirrors a rule that otherwise reaches fleet repos only. - - The `fleet-bootstrap` block carries `Fleet Governance Entry Point`, which names the template repository and routes by the state a repository is actually in. It is separate precisely because it enables rather than restricts, and the safety block's own text says nothing in it widens a permission, so merging the two would contradict that. It is host-wide rather than per repository because the repositories that most need it are the ones carrying no instruction set to point the way, and it mirrors the byte-locked `AGENTS.md` "Fleet Bootstrap" section that a conformant repository carries. -- **The permission rules this kit owns, merged into `settings.json`** beside the hook registration. Each is declared as a prefix and a rule, and a re-run drops every rule the prefix owns before adding the current one, so a rule whose spelling changes updates in place rather than accumulating beside the version it replaced. Ownership requires a rule-syntax delimiter after the prefix, since the prefix ends at the script name and a bare prefix test would also claim a longer path such as `pr_review.py-custom`, so a rule written by hand for a different script is never touched. These widen rather than restrict, which is why they are their own component for the same reason the `fleet-bootstrap` block is separate from the `agent-safety` one. Today the list holds one rule, for `scripts/pr_review.py`, the review loop's reply and resolve. Driving that loop by hand needs a raw GraphQL mutation carrying a node id, which is the shape that reached a stranger's repository, where the script queries the id itself and takes no argument an id fits in. What the rule decides is which command runs without a prompt, and it matches the command text rather than the directory the command runs in, so it reaches a `scripts/pr_review.py` in any checkout that carries one. An absolute path would not narrow that, since the hub is reached as a checkout of the caller's own and its location differs per task, so pinning one path would name a checkout the next task does not use. What bounds it is the rule that an agent reaches the hub as a checkout of its own, fetched immediately before it is read, rather than a copy it happens to find on disk, which the `fleet-bootstrap` block beside this carries and [`GOVERNANCE.md`][governance] "Hub-Hosted Tooling" states in full. - -The hook is the mechanical backstop. The CLAUDE.md rules and the carried GOVERNANCE.md rules are the behavioral layer. Prose alone is not enough, since the incident happened under prose rules, so both ship. The GitHub write rules have a hook behind them, as do the git operations that bypass a repository protection, while which checkout a command belongs in, the data a line of text quotes, and how an authorization is recorded are prose only, since no hook can see any of the three. - -## Install (Idempotent, Safe to Re-Run to Update) - -```sh -# Linux / WSL / macOS -host-setup/agent-safety/install.sh -``` - -```powershell -# Windows - the .\ prefix is required, PowerShell does not run a script from a relative path without it -.\host-setup\agent-safety\install.ps1 -``` - -Both are thin wrappers around `install.py`, so every OS runs one tested code path. The installer self-tests the hook before registering it, merges the settings.json hook entry and the permission rules without clobbering other keys, and updates each CLAUDE.md block in place by its own markers rather than duplicating it, so the two blocks move independently. The settings file is read once and written once, so the hook and the permission rules land together or not at all. - -**Restart Claude Code sessions on the machine afterward** so the new hook and CLAUDE.md load. - -## Refreshing After an Upstream Change - -The deployed copy on each machine is a snapshot, so when the guard changes upstream (a new rule or a fix) every machine keeps running the old hook until it is refreshed. The installer **is** the refresh: pull the latest template and re-run `install.sh` (or `install.ps1`) on each machine. It re-copies the hook, re-runs the self-test, and re-registers in place, so a re-run is safe and updates the deployed copy. [#365][issue-365] tracks the per-machine rollout and its re-runs. - -## Verify (POSIX Shell) - -```sh -python3 ~/.claude/hooks/gh-write-guard.py --selftest # decision matrix: all cases pass -grep -c 'agent-safety v' ~/.claude/CLAUDE.md # expect 2 (start + end marker) -grep -c 'fleet-bootstrap v' ~/.claude/CLAUDE.md # expect 2 (start + end marker) -grep -cF 'Bash(python3 scripts/pr_review.py:*)' ~/.claude/settings.json # expect 1 (never duplicated) -``` - -On Windows PowerShell: - -```powershell -py -3 "$env:USERPROFILE\.claude\hooks\gh-write-guard.py" --selftest # all cases pass -(Select-String 'agent-safety v' "$env:USERPROFILE\.claude\CLAUDE.md").Count # expect 2 -(Select-String 'fleet-bootstrap v' "$env:USERPROFILE\.claude\CLAUDE.md").Count # expect 2 -(Select-String -SimpleMatch 'Bash(python3 scripts/pr_review.py:*)' "$env:USERPROFILE\.claude\settings.json").Count # expect 1 -``` - -Live end-to-end (in any repo): attempt a discarded-output write and confirm the Bash tool is blocked: - -```sh -gh api graphql -f query='mutation{noop}' -F t="PRRT_x" >/dev/null 2>&1 || true # blocked by the hook -``` - -## Granting a Cross-Owner Write - -The cross-origin rule is the one denial a maintainer has to act on, because it is the only one with a grant behind it. The others name a shape to stop using, while this one names a target that may be entirely legitimate. - -`GH_WRITE_GUARD_ALLOW` is one string holding every grant, since it is an environment variable, and it is split into `owner/repo` tokens on any run of whitespace or commas, so `a/b c/d` and `a/b, c/d` are the same two grants. `owner/*` grants a whole owner. A token carrying no `/` is ignored, so a malformed grant grants nothing, and a repository grant does not extend to that owner's other repositories. - -The hook reads it from the environment the session was launched with, which is the one channel an agent cannot use on itself: the hook runs as its own process, so an inline `VAR=x cmd` prefix and an `export` inside a Bash call both leave the write denied. Granting is therefore a deliberate act taken outside the session, and a blocked agent asks rather than unblocks itself. - -The channel that works is an `env` block in the checkout's `.claude/settings.local.json`, which scopes the grant to sessions started in that checkout, followed by a session restart. The worked example, the fork case that raises this most often, and how to confirm a grant loaded without making the write are in [`docs/host-setup.md` "Granting a Write the Guard Denies"][host-setup-grant]. - -## Manual settings.json Shape (for Reference) - -The installer writes this. It is here so you can inspect or hand-place it: - -```json -{ - "hooks": { - "PreToolUse": [ - { "matcher": "Bash", "hooks": [ { "type": "command", "command": "\"python3\" \"<home>/.claude/hooks/gh-write-guard.py\"" } ] } - ] - }, - "permissions": { - "allow": [ "Bash(python3 scripts/pr_review.py:*)" ] - } -} -``` - -Every other key in the file is left as it stands, `permissions.allow` included, apart from the rules whose prefix this kit owns. - -## Scope and Limits - -- **Per-machine.** `~/.claude/` does not travel, so run the installer on each box. This is the rollout that [#365][issue-365] tracks. -- **Precision over recall for the write footguns.** The hook denies the specific dangerous write shapes with high confidence rather than gating every write, so it never blocks legitimate work. A shape it does not catch still falls under the behavioral rules. -- **The branch-bypass rule fails closed.** Unlike the write-footgun rules, a push to `main`/`master`/`develop` is denied even when its rules cannot be determined (the API is unreachable, or the checkout's origin cannot be resolved to query them), because the harm there is a silent success under the maintainer's admin bypass. The rule reads each branch's live rules, so it adapts to every repo (a code-style `develop` denies, a config-style `develop` allows) with no per-repo configuration, and hands the exact command to the maintainer to run when a bypass is genuinely intended. -- **Opaque targets are unseen.** The hook cannot see the repository behind a GraphQL node id, which is exactly why rule 2 blocks a *literal* id at all, since a captured `$variable` is trusted. Likewise, the cross-origin check only runs when an `origin` can be resolved and the write names an explicit `-R`/`repos/<owner>/<repo>` target. A write from a non-git directory, or one whose target is only a node id, is evaluated by rules 1 and 2 alone. -- **A write inside a script file is unseen, so never batch writes into a script.** The hook reads the command the agent runs, which for `bash deploy.sh` is that one string, so a `git push` or a `gh` mutation inside the script reaches the server without the hook ever parsing it. This is the widest hole in the kit and it is one an agent opens by accident, since collecting fifteen repetitive pushes into a loop looks like tidiness rather than like disabling a guard. Issue each write as its own command. A script that only reads, computes, or prepares local commits is fine, because the boundary is the write and not the script. -- **The hook's own parser over-blocks a `git push` followed by a newline.** Git and GitHub are not involved in this one: the hook splits the command string to find each `git push` and its arguments, and that split ends an argument list at `&&` but not at a newline, so it reads every token on a later line of the same command as a refspec of that push. Measured against the installed hook, `git push -u origin revendor/x` resolves to that one branch, while the same push followed by a newline and a `gh pr create` naming `develop` as its base resolves to five, meaning `revendor/x`, `gh`, `pr`, `create`, and `develop`. The hook then denies the push as a direct push to a protected branch that the push never named. The direction is safe, since it blocks rather than admits, but the denial names a bypass the agent never attempted, and a guard that cries wolf is one an agent starts working around. Until the parser is fixed, issue the push as its own command, which is the rule directly above in any case. Tracked in `TODO.md`. -- **Not a credential control.** A fine-grained PAT limited to owned repositories is a separate, stronger structural guard (a hard `403` on any non-owned repo) and is left to per-machine credential setup, out of this kit. - -<!-- Repo --> -[governance]: ../../GOVERNANCE.md -[host-setup-grant]: ../../docs/host-setup.md#granting-a-write-the-guard-denies -[issue-365]: https://github.com/ptr727/ProjectTemplate/issues/365 +# Agent Write-Safety Kit + +Per-machine, user-account-scoped guards against an agent making a mis-targeted GitHub **write** under the maintainer's identity, or a **git operation that bypasses a branch rule or overrides a required check**, meaning a push, force-push, or delete that an active branch rule forbids, or an override flag (`--admin` past the server-side merge gate, `--no-verify` past the local git hooks). Deploy it as the **first thing on any new system** where Claude Code runs with the `gh` credentials logged in (Linux, WSL, macOS, Windows). + +## What It Installs + +Into `~/.claude/` (or `%USERPROFILE%\.claude\` on Windows): + +- **`hooks/gh-write-guard.py`**: a PreToolUse hook that denies two classes of dangerous action. First, the GitHub **write** footguns behind the cross-repo comment incident: a state-changing `gh` call whose output is discarded, a GraphQL mutation passing a **literal** node id instead of a `$variable`, and a `gh` write whose explicit target is under an owner other than the checkout origin's. Sibling repositories under the same owner are allowed, since the harm this guards is reaching a stranger's repository rather than working across one maintainer's own fleet, and a different owner is allowed only when the maintainer names it in `GH_WRITE_GUARD_ALLOW` (an `owner/repo` list, where `owner/*` grants a whole owner). That variable is read from the environment the session was launched with, which is the one channel an agent cannot set for itself: a hook runs as its own process, so an inline `VAR=x cmd` prefix or an `export` inside a Bash call never reaches it. Second, a **git operation that bypasses a repository protection**. The branch-rule cases (a direct push to a branch that requires a pull request, a force-push where history is protected, a branch delete where deletion is blocked) are judged against the branch's **live** rules, so a code-style `develop` is denied while a config-style `develop` is allowed with no per-repo configuration. A push to a protected-default branch fails closed when its rules cannot be determined, either because the API is unreachable or because the checkout's origin cannot be resolved to query them. The explicit-bypass flags are denied **unconditionally**, since the flag is itself the bypass and needs no branch query: `gh pr merge --admin` overrides the server-side merge gate, and `git commit`/`git push --no-verify` skips the local git hooks. Reads and everything else pass through. It fires even in autonomous / bypass-permissions sessions, which is how the incident happened. +- **Two marker-delimited blocks of host-wide content in `CLAUDE.md`**, loaded into every session on the machine (including ad-hoc work outside any project), installed and updated independently so one can change without rewriting the other. + - The `agent-safety` block carries restrictions alone, so nothing in it can widen a permission. `Repository Boundaries and Write Safety` mirrors the committed `GOVERNANCE.md` section of the same name, carrying as behavioral guidance its write rules and its rules that a task works in its own checkout and isolates into its own worktree before its first file edit. `Data in Agent-Authored Text` mirrors `GOVERNANCE.md` "Representative Data in Agent-Authored Text", so an agent illustrates with constructed data rather than data observed on the machine. `Authorization Scope and Memory Hygiene` bounds how a granted permission is recorded and read. Each mirrors a rule that otherwise reaches fleet repos only. + - The `fleet-bootstrap` block carries `Fleet Governance Entry Point`, which names the template repository and routes by the state a repository is actually in. It is separate precisely because it enables rather than restricts, and the safety block's own text says nothing in it widens a permission, so merging the two would contradict that. It is host-wide rather than per repository because the repositories that most need it are the ones carrying no instruction set to point the way, and it mirrors the byte-locked `AGENTS.md` "Fleet Bootstrap" section that a conformant repository carries. +- **The permission rules this kit owns, merged into `settings.json`** beside the hook registration. Each is declared as a prefix and a rule, and a re-run drops every rule the prefix owns before adding the current one, so a rule whose spelling changes updates in place rather than accumulating beside the version it replaced. Ownership requires a rule-syntax delimiter after the prefix, since the prefix ends at the script name and a bare prefix test would also claim a longer path such as `pr_review.py-custom`, so a rule written by hand for a different script is never touched. These widen rather than restrict, which is why they are their own component for the same reason the `fleet-bootstrap` block is separate from the `agent-safety` one. Today the list holds one rule, for `scripts/pr_review.py`, the review loop's reply and resolve. Driving that loop by hand needs a raw GraphQL mutation carrying a node id, which is the shape that reached a stranger's repository, where the script queries the id itself and takes no argument an id fits in. What the rule decides is which command runs without a prompt, and it matches the command text rather than the directory the command runs in, so it reaches a `scripts/pr_review.py` in any checkout that carries one. An absolute path would not narrow that, since the hub is reached as a checkout of the caller's own and its location differs per task, so pinning one path would name a checkout the next task does not use. What bounds it is the rule that an agent reaches the hub as a checkout of its own, fetched immediately before it is read, rather than a copy it happens to find on disk, which the `fleet-bootstrap` block beside this carries and [`GOVERNANCE.md`][governance] "Hub-Hosted Tooling" states in full. + +The hook is the mechanical backstop. The CLAUDE.md rules and the carried GOVERNANCE.md rules are the behavioral layer. Prose alone is not enough, since the incident happened under prose rules, so both ship. The GitHub write rules have a hook behind them, as do the git operations that bypass a repository protection, while which checkout a command belongs in, the data a line of text quotes, and how an authorization is recorded are prose only, since no hook can see any of the three. + +## Install (Idempotent, Safe to Re-Run to Update) + +```sh +# Linux / WSL / macOS +host-setup/agent-safety/install.sh +``` + +```powershell +# Windows - the .\ prefix is required, PowerShell does not run a script from a relative path without it +.\host-setup\agent-safety\install.ps1 +``` + +Both are thin wrappers around `install.py`, so every OS runs one tested code path. The installer self-tests the hook before registering it, merges the settings.json hook entry and the permission rules without clobbering other keys, and updates each CLAUDE.md block in place by its own markers rather than duplicating it, so the two blocks move independently. The settings file is read once and written once, so the hook and the permission rules land together or not at all. + +**Restart Claude Code sessions on the machine afterward** so the new hook and CLAUDE.md load. + +## Refreshing After an Upstream Change + +The deployed copy on each machine is a snapshot, so when the guard changes upstream (a new rule or a fix) every machine keeps running the old hook until it is refreshed. The installer **is** the refresh: pull the latest template and re-run `install.sh` (or `install.ps1`) on each machine. It re-copies the hook, re-runs the self-test, and re-registers in place, so a re-run is safe and updates the deployed copy. [#365][issue-365] tracks the per-machine rollout and its re-runs. + +## Verify (POSIX Shell) + +```sh +python3 ~/.claude/hooks/gh-write-guard.py --selftest # decision matrix: all cases pass +grep -c 'agent-safety v' ~/.claude/CLAUDE.md # expect 2 (start + end marker) +grep -c 'fleet-bootstrap v' ~/.claude/CLAUDE.md # expect 2 (start + end marker) +grep -cF 'Bash(python3 scripts/pr_review.py:*)' ~/.claude/settings.json # expect 1 (never duplicated) +``` + +On Windows PowerShell: + +```powershell +py -3 "$env:USERPROFILE\.claude\hooks\gh-write-guard.py" --selftest # all cases pass +(Select-String 'agent-safety v' "$env:USERPROFILE\.claude\CLAUDE.md").Count # expect 2 +(Select-String 'fleet-bootstrap v' "$env:USERPROFILE\.claude\CLAUDE.md").Count # expect 2 +(Select-String -SimpleMatch 'Bash(python3 scripts/pr_review.py:*)' "$env:USERPROFILE\.claude\settings.json").Count # expect 1 +``` + +Live end-to-end (in any repo): attempt a discarded-output write and confirm the Bash tool is blocked: + +```sh +gh api graphql -f query='mutation{noop}' -F t="PRRT_x" >/dev/null 2>&1 || true # blocked by the hook +``` + +## Granting a Cross-Owner Write + +The cross-origin rule is the one denial a maintainer has to act on, because it is the only one with a grant behind it. The others name a shape to stop using, while this one names a target that may be entirely legitimate. + +`GH_WRITE_GUARD_ALLOW` is one string holding every grant, since it is an environment variable, and it is split into `owner/repo` tokens on any run of whitespace or commas, so `a/b c/d` and `a/b, c/d` are the same two grants. `owner/*` grants a whole owner. A token carrying no `/` is ignored, so a malformed grant grants nothing, and a repository grant does not extend to that owner's other repositories. + +The hook reads it from the environment the session was launched with, which is the one channel an agent cannot use on itself: the hook runs as its own process, so an inline `VAR=x cmd` prefix and an `export` inside a Bash call both leave the write denied. Granting is therefore a deliberate act taken outside the session, and a blocked agent asks rather than unblocks itself. + +The channel that works is an `env` block in the checkout's `.claude/settings.local.json`, which scopes the grant to sessions started in that checkout, followed by a session restart. The worked example, the fork case that raises this most often, and how to confirm a grant loaded without making the write are in [`docs/host-setup.md` "Granting a Write the Guard Denies"][host-setup-grant]. + +## Manual settings.json Shape (for Reference) + +The installer writes this. It is here so you can inspect or hand-place it: + +```json +{ + "hooks": { + "PreToolUse": [ + { "matcher": "Bash", "hooks": [ { "type": "command", "command": "\"python3\" \"<home>/.claude/hooks/gh-write-guard.py\"" } ] } + ] + }, + "permissions": { + "allow": [ "Bash(python3 scripts/pr_review.py:*)" ] + } +} +``` + +Every other key in the file is left as it stands, `permissions.allow` included, apart from the rules whose prefix this kit owns. + +## Scope and Limits + +- **Per-machine.** `~/.claude/` does not travel, so run the installer on each box. This is the rollout that [#365][issue-365] tracks. +- **Precision over recall for the write footguns.** The hook denies the specific dangerous write shapes with high confidence rather than gating every write, so it never blocks legitimate work. A shape it does not catch still falls under the behavioral rules. +- **The branch-bypass rule fails closed.** Unlike the write-footgun rules, a push to `main`/`master`/`develop` is denied even when its rules cannot be determined (the API is unreachable, or the checkout's origin cannot be resolved to query them), because the harm there is a silent success under the maintainer's admin bypass. The rule reads each branch's live rules, so it adapts to every repo (a code-style `develop` denies, a config-style `develop` allows) with no per-repo configuration, and hands the exact command to the maintainer to run when a bypass is genuinely intended. +- **Opaque targets are unseen.** The hook cannot see the repository behind a GraphQL node id, which is exactly why rule 2 blocks a *literal* id at all, since a captured `$variable` is trusted. Likewise, the cross-origin check only runs when an `origin` can be resolved and the write names an explicit `-R`/`repos/<owner>/<repo>` target. A write from a non-git directory, or one whose target is only a node id, is evaluated by rules 1 and 2 alone. +- **A write inside a script file is unseen, so never batch writes into a script.** The hook reads the command the agent runs, which for `bash deploy.sh` is that one string, so a `git push` or a `gh` mutation inside the script reaches the server without the hook ever parsing it. This is the widest hole in the kit and it is one an agent opens by accident, since collecting fifteen repetitive pushes into a loop looks like tidiness rather than like disabling a guard. Issue each write as its own command. A script that only reads, computes, or prepares local commits is fine, because the boundary is the write and not the script. +- **The hook's own parser over-blocks a `git push` followed by a newline.** Git and GitHub are not involved in this one: the hook splits the command string to find each `git push` and its arguments, and that split ends an argument list at `&&` but not at a newline, so it reads every token on a later line of the same command as a refspec of that push. Measured against the installed hook, `git push -u origin revendor/x` resolves to that one branch, while the same push followed by a newline and a `gh pr create` naming `develop` as its base resolves to five, meaning `revendor/x`, `gh`, `pr`, `create`, and `develop`. The hook then denies the push as a direct push to a protected branch that the push never named. The direction is safe, since it blocks rather than admits, but the denial names a bypass the agent never attempted, and a guard that cries wolf is one an agent starts working around. Until the parser is fixed, issue the push as its own command, which is the rule directly above in any case. Tracked in `TODO.md`. +- **Not a credential control.** A fine-grained PAT limited to owned repositories is a separate, stronger structural guard (a hard `403` on any non-owned repo) and is left to per-machine credential setup, out of this kit. + +<!-- Repo --> +[governance]: ../../GOVERNANCE.md +[host-setup-grant]: ../../docs/host-setup.md#granting-a-write-the-guard-denies +[issue-365]: https://github.com/ptr727/ProjectTemplate/issues/365 diff --git a/host-setup/agent-safety/claude-md-fleet.md b/host-setup/agent-safety/claude-md-fleet.md index e390af43..4af94160 100644 --- a/host-setup/agent-safety/claude-md-fleet.md +++ b/host-setup/agent-safety/claude-md-fleet.md @@ -1,12 +1,12 @@ -<!-- fleet-bootstrap v1 start --> -## Fleet Governance Entry Point (Any Project, Every Session) - -Repositories under the `ptr727` owner are governed by a shared template at `github.com/ptr727/ProjectTemplate`, the repository these rules call the hub, which holds the canonical rules, the machine-readable spec, the repository registry, and the procedures for standing a repository up and bringing one back to conformance. Fetch that repository before acting on anything about conformance, carried content, repository settings, or standing a repository up. This section is host-wide rather than per repository, deliberately, because the repositories that most need it are the ones carrying no instruction set to point the way, and a pointer that lives only inside a conformant repository is a pointer that is present exactly when it is not needed. A repository carrying its own `AGENTS.md` states the same routing in a byte-locked "Fleet Bootstrap" section, and the two are kept in sync on purpose. - -This section enables rather than restricts, so it is bounded by everything above it. It is not an authorization to write anywhere, and the write-safety rules govern every action it leads to. Reading the hub, reading any repository, and running an audit are reads, and they need nothing. A change to a repository is a write and follows the ordinary rules, meaning a branch, a pull request, and a maintainer's merge. - -- **Route by what the repository actually holds, not by what it should hold.** The two differ precisely when this matters. No repository yet means the hub's `STANDUP.md` from section 0, and note that nothing in that file creates the GitHub repository, which is an outward-facing write needing explicit permission. A missing or partial instruction set means carrying the baseline per that file's sections 1A and 2, since absent files are a baseline that never arrived rather than drift to re-vendor. An instruction set that is present, current or stale, means the hub's `RESYNC.md`, which runs `AUDIT.md` end to end for the findings and then applies each one in the order that file sets. -- **Read the hub's `main` branch as ground truth**, since that is the promoted and gated state, and read `develop` only to detect divergence. The same holds for a repository being audited, for both workflow models. -- **The audit is read-only and produces a report.** It never edits the repository it measures, so a fix is a separate and reviewable change. An audit that reports drift and stops is half the procedure, and a conformance claim carrying no committed report is a claim nobody can check. -- **Reach the hub as a checkout of your own, fetched immediately before it is read.** A clone is whatever it last fetched rather than the branch it names, so a stale one answers confidently instead of failing, and a single file lifted out of the tree runs against whatever the caller happens to have. Work only in that checkout rather than in one that another task is using, since a blanket add, a hard reset, or a branch switch in a tree someone else is editing destroys work while every command is individually correct. -<!-- fleet-bootstrap v1 end --> +<!-- fleet-bootstrap v1 start --> +## Fleet Governance Entry Point (Any Project, Every Session) + +Repositories under the `ptr727` owner are governed by a shared template at `github.com/ptr727/ProjectTemplate`, the repository these rules call the hub, which holds the canonical rules, the machine-readable spec, the repository registry, and the procedures for standing a repository up and bringing one back to conformance. Fetch that repository before acting on anything about conformance, carried content, repository settings, or standing a repository up. This section is host-wide rather than per repository, deliberately, because the repositories that most need it are the ones carrying no instruction set to point the way, and a pointer that lives only inside a conformant repository is a pointer that is present exactly when it is not needed. A repository carrying its own `AGENTS.md` states the same routing in a byte-locked "Fleet Bootstrap" section, and the two are kept in sync on purpose. + +This section enables rather than restricts, so it is bounded by everything above it. It is not an authorization to write anywhere, and the write-safety rules govern every action it leads to. Reading the hub, reading any repository, and running an audit are reads, and they need nothing. A change to a repository is a write and follows the ordinary rules, meaning a branch, a pull request, and a maintainer's merge. + +- **Route by what the repository actually holds, not by what it should hold.** The two differ precisely when this matters. No repository yet means the hub's `STANDUP.md` from section 0, and note that nothing in that file creates the GitHub repository, which is an outward-facing write needing explicit permission. A missing or partial instruction set means carrying the baseline per that file's sections 1A and 2, since absent files are a baseline that never arrived rather than drift to re-vendor. An instruction set that is present, current or stale, means the hub's `RESYNC.md`, which runs `AUDIT.md` end to end for the findings and then applies each one in the order that file sets. +- **Read the hub's `main` branch as ground truth**, since that is the promoted and gated state, and read `develop` only to detect divergence. The same holds for a repository being audited, for both workflow models. +- **The audit is read-only and produces a report.** It never edits the repository it measures, so a fix is a separate and reviewable change. An audit that reports drift and stops is half the procedure, and a conformance claim carrying no committed report is a claim nobody can check. +- **Reach the hub as a checkout of your own, fetched immediately before it is read.** A clone is whatever it last fetched rather than the branch it names, so a stale one answers confidently instead of failing, and a single file lifted out of the tree runs against whatever the caller happens to have. Work only in that checkout rather than in one that another task is using, since a blanket add, a hard reset, or a branch switch in a tree someone else is editing destroys work while every command is individually correct. +<!-- fleet-bootstrap v1 end --> diff --git a/host-setup/agent-safety/claude-md-safety.md b/host-setup/agent-safety/claude-md-safety.md index 3a465767..54a52a87 100644 --- a/host-setup/agent-safety/claude-md-safety.md +++ b/host-setup/agent-safety/claude-md-safety.md @@ -1,30 +1,30 @@ -<!-- agent-safety v1 start --> -## Repository Boundaries and Write Safety (Any Project, Every Session) - -A `gh` / GitHub API write runs under the logged-in identity, so a mis-targeted write acts publicly as that account on someone else's repository, an outward-facing and hard-to-reverse act. These rules bound every write (a git push, an API mutation, a comment, a label, a merge) in every session on this machine, including ad-hoc work outside any project, and they bound a write to a checkout on disk as well, since a blanket add or a hard reset in a working tree another task is using destroys work without ever reaching GitHub. Reads are unrestricted. A committed repo's `GOVERNANCE.md` "Repository Boundaries and Write Safety" states the same rules for its fleet, and the two are kept in sync deliberately, because this file also covers sessions that `AGENTS.md` never reaches. The `gh-write-guard` PreToolUse hook enforces the mechanical half. - -- **Write only within the owner of the current checkout's repository.** Every state-changing call targets this checkout's `origin` or a sibling repository under the same owner. A broad or logged-in identity is capability, not permission. A repository under a **different owner** needs explicit human permission naming it, set in `GH_WRITE_GUARD_ALLOW` before the session starts rather than granted by the agent to itself, and a "harmless test" write is still a write. -- **Never fabricate, guess, or reuse an identifier passed to a write.** Every id a write consumes (a node id, a numeric id, a thread or comment id) is captured from a live query in the same session into a variable and passed from there. Ids resolve globally, so a wrong-but-valid id does not fail. It writes to the wrong target, in another repository. If a query returns no id, stop rather than invent one. -- **A write is never a probe, and a write's output is never suppressed.** Never fire a state-changing call to see whether it works, and never append an output-discarding or force-success tail (for example `>/dev/null`, `2>/dev/null`, `&>/dev/null`, `|| true`, `|| :`, `|| echo`) to a mutation. A write that appears to fail is verified, not assumed harmless, because it may have succeeded on the server. -- **Each task runs in its own checkout, in its own directory, on its own feature branch.** The unit is the task rather than the agent, since one agent moving between two repositories meets the same hazard as two agents sharing one tree. A blanket `git add -A`, a `git reset --hard`, and a branch switch each cross the boundary while being correct in isolation, so a mutating command takes an absolute path, or a `cd` to one in the same invocation, rather than the working directory it inherited. A branch that changes when nothing you did changed it, or an edit of yours reverted with no conflict, means another task is live in that tree, and the response is to stop rather than to re-apply the edit. -- **A task isolates into its own git worktree before its first file edit, and a continuation re-isolates.** All new work, a continuation of a prior session's task included, begins by creating a unique worktree (or clone) on its own feature branch, based on the branch work starts on for that repository rather than whichever branch a tool defaulted to. The primary checkout is the maintainer's own surface, so a session launched there isolates before writing rather than after noticing contention. Staging is by explicit path, never `git add -A` or `git add .`, since a blanket add is how a shared tree turns into a mis-scoped commit. A committed repo's `GOVERNANCE.md` "Repository Boundaries and Write Safety" and the hub's `repo-worktree` skill carry the full rule and the mechanics. - -## Data in Agent-Authored Text (Any Project, Every Session) - -Agent-authored text illustrates with data the agent constructed, never with data it observed in the maintainer's environment. This binds every surface the agent authors, meaning pull request and issue comments, commit messages, code, tests, fixtures, and docs, and it holds in every session on this machine, including ad-hoc work outside any project. Reading real data is unrestricted, and what is bounded is copying it out of the environment into text that is committed or posted. A committed repo's `GOVERNANCE.md` "Representative Data in Agent-Authored Text" states the same rule for its fleet, and the two are kept in sync deliberately, because this file also covers sessions that `AGENTS.md` never reaches. - -- **Synthetic evidence is the better evidence, not a weaker substitute.** A constructed case demonstrates the defect exactly and any reader can re-run it, where observed data proves the same thing and can never be reproduced by anyone else. Where observed data is what revealed the defect, name the property that triggers the fault and construct a case that carries that property. -- **The exposure is one-way.** A public comment is fetched, cached, and indexed the moment it posts, so an edit afterwards is mitigation rather than a fix, and it leaves the original readable in the comment's edit history. Report text that has already landed rather than quietly rewriting it, since what to do about it, deletion included, is the maintainer's call. Do not quote the exposed data again while reporting or investigating it. -- **No pattern closes this.** A check finds an absolute home path or a drive letter and nothing beyond that shape. The exposure this rule exists for was name-shaped, and a name is not pattern-detectable, so a search for path-shaped strings returns nothing while the names sit in plain sight. This is a judgment applied to every line written, never a check to wait for. - -## Authorization Scope and Memory Hygiene (Any Project, Every Session) - -A permission the maintainer grants is bounded by what he said, where he said it, and when. Memory is how those bounds get lost, because a note outlives the session that justified it and a grant given for one repository is later read as a mode. This section is a backstop on how an authorization is recorded and read, and it holds in every project because the failure is in the mechanism rather than in any one project. It is a restriction and never a grant, which is why it belongs in this host-wide block: only restrictions live in the `agent-safety` block, so nothing in it can widen a permission. Another marker block in the same file may state enabling guidance, and where the two meet the restriction binds, since a pointer to what to read next never authorizes a write. - -- **A committed rule outranks a memory, always.** The rules in a repository's own `AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, or equivalent are the law. A memory is a private note about a past session, and it never supersedes, retires, or relaxes one. Where a memory and a committed rule disagree, follow the rule, say the memory looks stale, and ask. A memory asserting that a documented default has been "retired" or "superseded" is the specific claim to distrust. -- **Record a grant with its scope and its lifetime, both explicit.** Scope is the narrowest of repository, project, or fleet that he actually named. Lifetime defaults to **this session only**. Write a grant as standing solely when he said it stands, and quote the words that said so. An unscoped or undated authorization in memory is read as expired rather than as broad. -- **Storage location is not scope.** A note in one project's memory directory can still assert authority over other repositories, and the directory holding it does not bound what it claims. State the scope in the text. A project-scoped file claiming fleet-wide authority is a defect to fix on sight rather than a convenience to rely on. -- **Never widen a grant by inference.** Permission for one repository does not carry to a sibling, permission for one pull request does not carry to the next, and permission for one task does not become a mode for the session. Similarity is not authorization. Re-ask instead, because re-asking is cheap and an unwanted write is not. -- **Never grant to yourself.** Do not record an authorization he did not give, infer one from a tool's capability, or restore one he narrowed or withdrew. Capability is not permission. -- **The irreversible step stays his.** Merging, publishing, releasing, force-pushing, deleting, and changing branch protection each stop for an explicit and current go-ahead, however green the work is and whatever a memory says about a past session. -<!-- agent-safety v1 end --> +<!-- agent-safety v1 start --> +## Repository Boundaries and Write Safety (Any Project, Every Session) + +A `gh` / GitHub API write runs under the logged-in identity, so a mis-targeted write acts publicly as that account on someone else's repository, an outward-facing and hard-to-reverse act. These rules bound every write (a git push, an API mutation, a comment, a label, a merge) in every session on this machine, including ad-hoc work outside any project, and they bound a write to a checkout on disk as well, since a blanket add or a hard reset in a working tree another task is using destroys work without ever reaching GitHub. Reads are unrestricted. A committed repo's `GOVERNANCE.md` "Repository Boundaries and Write Safety" states the same rules for its fleet, and the two are kept in sync deliberately, because this file also covers sessions that `AGENTS.md` never reaches. The `gh-write-guard` PreToolUse hook enforces the mechanical half. + +- **Write only within the owner of the current checkout's repository.** Every state-changing call targets this checkout's `origin` or a sibling repository under the same owner. A broad or logged-in identity is capability, not permission. A repository under a **different owner** needs explicit human permission naming it, set in `GH_WRITE_GUARD_ALLOW` before the session starts rather than granted by the agent to itself, and a "harmless test" write is still a write. +- **Never fabricate, guess, or reuse an identifier passed to a write.** Every id a write consumes (a node id, a numeric id, a thread or comment id) is captured from a live query in the same session into a variable and passed from there. Ids resolve globally, so a wrong-but-valid id does not fail. It writes to the wrong target, in another repository. If a query returns no id, stop rather than invent one. +- **A write is never a probe, and a write's output is never suppressed.** Never fire a state-changing call to see whether it works, and never append an output-discarding or force-success tail (for example `>/dev/null`, `2>/dev/null`, `&>/dev/null`, `|| true`, `|| :`, `|| echo`) to a mutation. A write that appears to fail is verified, not assumed harmless, because it may have succeeded on the server. +- **Each task runs in its own checkout, in its own directory, on its own feature branch.** The unit is the task rather than the agent, since one agent moving between two repositories meets the same hazard as two agents sharing one tree. A blanket `git add -A`, a `git reset --hard`, and a branch switch each cross the boundary while being correct in isolation, so a mutating command takes an absolute path, or a `cd` to one in the same invocation, rather than the working directory it inherited. A branch that changes when nothing you did changed it, or an edit of yours reverted with no conflict, means another task is live in that tree, and the response is to stop rather than to re-apply the edit. +- **A task isolates into its own git worktree before its first file edit, and a continuation re-isolates.** All new work, a continuation of a prior session's task included, begins by creating a unique worktree (or clone) on its own feature branch, based on the branch work starts on for that repository rather than whichever branch a tool defaulted to. The primary checkout is the maintainer's own surface, so a session launched there isolates before writing rather than after noticing contention. Staging is by explicit path, never `git add -A` or `git add .`, since a blanket add is how a shared tree turns into a mis-scoped commit. A committed repo's `GOVERNANCE.md` "Repository Boundaries and Write Safety" and the hub's `repo-worktree` skill carry the full rule and the mechanics. + +## Data in Agent-Authored Text (Any Project, Every Session) + +Agent-authored text illustrates with data the agent constructed, never with data it observed in the maintainer's environment. This binds every surface the agent authors, meaning pull request and issue comments, commit messages, code, tests, fixtures, and docs, and it holds in every session on this machine, including ad-hoc work outside any project. Reading real data is unrestricted, and what is bounded is copying it out of the environment into text that is committed or posted. A committed repo's `GOVERNANCE.md` "Representative Data in Agent-Authored Text" states the same rule for its fleet, and the two are kept in sync deliberately, because this file also covers sessions that `AGENTS.md` never reaches. + +- **Synthetic evidence is the better evidence, not a weaker substitute.** A constructed case demonstrates the defect exactly and any reader can re-run it, where observed data proves the same thing and can never be reproduced by anyone else. Where observed data is what revealed the defect, name the property that triggers the fault and construct a case that carries that property. +- **The exposure is one-way.** A public comment is fetched, cached, and indexed the moment it posts, so an edit afterwards is mitigation rather than a fix, and it leaves the original readable in the comment's edit history. Report text that has already landed rather than quietly rewriting it, since what to do about it, deletion included, is the maintainer's call. Do not quote the exposed data again while reporting or investigating it. +- **No pattern closes this.** A check finds an absolute home path or a drive letter and nothing beyond that shape. The exposure this rule exists for was name-shaped, and a name is not pattern-detectable, so a search for path-shaped strings returns nothing while the names sit in plain sight. This is a judgment applied to every line written, never a check to wait for. + +## Authorization Scope and Memory Hygiene (Any Project, Every Session) + +A permission the maintainer grants is bounded by what he said, where he said it, and when. Memory is how those bounds get lost, because a note outlives the session that justified it and a grant given for one repository is later read as a mode. This section is a backstop on how an authorization is recorded and read, and it holds in every project because the failure is in the mechanism rather than in any one project. It is a restriction and never a grant, which is why it belongs in this host-wide block: only restrictions live in the `agent-safety` block, so nothing in it can widen a permission. Another marker block in the same file may state enabling guidance, and where the two meet the restriction binds, since a pointer to what to read next never authorizes a write. + +- **A committed rule outranks a memory, always.** The rules in a repository's own `AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, or equivalent are the law. A memory is a private note about a past session, and it never supersedes, retires, or relaxes one. Where a memory and a committed rule disagree, follow the rule, say the memory looks stale, and ask. A memory asserting that a documented default has been "retired" or "superseded" is the specific claim to distrust. +- **Record a grant with its scope and its lifetime, both explicit.** Scope is the narrowest of repository, project, or fleet that he actually named. Lifetime defaults to **this session only**. Write a grant as standing solely when he said it stands, and quote the words that said so. An unscoped or undated authorization in memory is read as expired rather than as broad. +- **Storage location is not scope.** A note in one project's memory directory can still assert authority over other repositories, and the directory holding it does not bound what it claims. State the scope in the text. A project-scoped file claiming fleet-wide authority is a defect to fix on sight rather than a convenience to rely on. +- **Never widen a grant by inference.** Permission for one repository does not carry to a sibling, permission for one pull request does not carry to the next, and permission for one task does not become a mode for the session. Similarity is not authorization. Re-ask instead, because re-asking is cheap and an unwanted write is not. +- **Never grant to yourself.** Do not record an authorization he did not give, infer one from a tool's capability, or restore one he narrowed or withdrew. Capability is not permission. +- **The irreversible step stays his.** Merging, publishing, releasing, force-pushing, deleting, and changing branch protection each stop for an explicit and current go-ahead, however green the work is and whatever a memory says about a past session. +<!-- agent-safety v1 end --> diff --git a/host-setup/agent-safety/install.ps1 b/host-setup/agent-safety/install.ps1 index 09d1f2f8..e465c4bd 100644 --- a/host-setup/agent-safety/install.ps1 +++ b/host-setup/agent-safety/install.ps1 @@ -1,31 +1,31 @@ -# Thin wrapper: run the cross-platform installer with a Python 3 (Windows). -# All logic lives in install.py so every OS runs one tested code path. -# It is idempotent and safe to re-run. -# .\install.ps1 -# $env:CLAUDE_HOME = "C:\path"; .\install.ps1 # override the target (testing) -$ErrorActionPreference = "Stop" -$here = Split-Path -Parent $MyInvocation.MyCommand.Path -$script = Join-Path $here "install.py" - -# Prefer launchers that are unambiguously Python 3. -# The installer and the hook use Python 3 syntax, so a bare `python`, which is Python 2 on some systems, is the last resort. -if (Get-Command "py" -ErrorAction SilentlyContinue) { - & py -3 $script @args -} elseif (Get-Command "python3" -ErrorAction SilentlyContinue) { - & python3 $script @args -} elseif (Get-Command "python" -ErrorAction SilentlyContinue) { - # Verify a bare `python` is Python 3 before handing it Python 3 syntax, since it is Python 2 on some setups and would fail to parse install.py. - # The two branches above are Python 3 by construction, so only this one needs the check. - & python -c "import sys; sys.exit(0 if sys.version_info[0] == 3 else 1)" 2>$null - if ($LASTEXITCODE -ne 0) { - Write-Error "Found python on PATH but it is not Python 3 (tried py -3, python3, python). Install Python 3." - exit 1 - } - & python $script @args -} else { - Write-Error "Python 3 is required and was not found on PATH (tried py -3, python3, python)." - exit 1 -} - -# Propagate the installer's exit code - a native command's non-zero exit does not stop the script. -exit $LASTEXITCODE +# Thin wrapper: run the cross-platform installer with a Python 3 (Windows). +# All logic lives in install.py so every OS runs one tested code path. +# It is idempotent and safe to re-run. +# .\install.ps1 +# $env:CLAUDE_HOME = "C:\path"; .\install.ps1 # override the target (testing) +$ErrorActionPreference = "Stop" +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +$script = Join-Path $here "install.py" + +# Prefer launchers that are unambiguously Python 3. +# The installer and the hook use Python 3 syntax, so a bare `python`, which is Python 2 on some systems, is the last resort. +if (Get-Command "py" -ErrorAction SilentlyContinue) { + & py -3 $script @args +} elseif (Get-Command "python3" -ErrorAction SilentlyContinue) { + & python3 $script @args +} elseif (Get-Command "python" -ErrorAction SilentlyContinue) { + # Verify a bare `python` is Python 3 before handing it Python 3 syntax, since it is Python 2 on some setups and would fail to parse install.py. + # The two branches above are Python 3 by construction, so only this one needs the check. + & python -c "import sys; sys.exit(0 if sys.version_info[0] == 3 else 1)" 2>$null + if ($LASTEXITCODE -ne 0) { + Write-Error "Found python on PATH but it is not Python 3 (tried py -3, python3, python). Install Python 3." + exit 1 + } + & python $script @args +} else { + Write-Error "Python 3 is required and was not found on PATH (tried py -3, python3, python)." + exit 1 +} + +# Propagate the installer's exit code - a native command's non-zero exit does not stop the script. +exit $LASTEXITCODE diff --git a/host-setup/bootstrap.ps1 b/host-setup/bootstrap.ps1 index 0a119020..7bc19cda 100644 --- a/host-setup/bootstrap.ps1 +++ b/host-setup/bootstrap.ps1 @@ -1,437 +1,437 @@ -# Stands a host up from nothing, by fetching this repository and handing control to the host tooling inside it. -# It is the one file fetched on its own, because a host with no git and no checkout is what it exists to fix. -# It reads no payload, no table, and no sibling module: it obtains a tree and runs one entry point inside that tree. -# -# A tarball rather than a clone, because a clone needs git on a host that may not have it, and because a tarball of a resolved commit cannot be stale. -# The commit it resolved is printed before anything runs, so a run says which revision of the fleet's tooling it used. -# -# An unverified loader is worse than none, which is why this one does more than its Linux peer before it trusts anything. -# It pins TLS 1.2 itself rather than assume a fresh host's default reaches GitHub, it checks a fetched tree for the marker it wrote before removing anything under -Dir, and it hands off to PowerShell 7 explicitly rather than assume the console it started in already carries it. -# -# Windows PowerShell 5.1 is the one shell a fresh Windows host guarantees, the way bash is guaranteed on a fresh Debian host, so everything up to and including finding or installing PowerShell 7 is written to run under it. -# Once pwsh is confirmed, this file hands the rest of the run to itself under pwsh, and every script under host-setup/windows it goes on to drive requires that version too. - -# The Host action binds to StandUpHost rather than Host, because Host is PowerShell's own automatic variable for the host program ($Host.Name, $Host.UI), and a parameter named Host would shadow it and fail PSAvoidAssignmentToAutomaticVariable. -# The alias keeps -Host as the spelling a caller types, while leaving the automatic variable free for the console-detection checks below. -[CmdletBinding()] -param( - [Alias('r')][switch]$Report, - [Alias('Host')][switch]$StandUpHost, - [switch]$Dev, - [switch]$Upgrade, - [switch]$Tools, - [switch]$Github, - [switch]$Skills, - [Alias('w')][switch]$Wsl, - [Alias('n')][switch]$DryRun, - [Alias('y')][switch]$Yes, - [string]$Ref, - [string]$Dir, - [switch]$Keep, - [Alias('h')][switch]$Help -) - -# Captured before anything else touches scope, so the pwsh handoff below can rebuild the argument list this process was bound with, rather than reaching for an automatic variable from inside a nested function. -$SCRIPT_BOUND_PARAMETERS = $PSBoundParameters - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' -# A non-zero exit from winget, tar or a driven script is an answer here rather than a failure. -# Setting this keeps a profile that turned it on from turning every read into a terminating error. -$PSNativeCommandUseErrorActionPreference = $false - -# A fresh Windows host's default TLS floor can predate 1.2, which raw.githubusercontent.com and the GitHub API both require. -# Set once, unconditionally, since it is a no-op where the runtime already defaults higher. -[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 - -$REPO = 'ptr727/ProjectTemplate' -$DEFAULT_REF = 'main' - -# --- Output --- - -function log { param([string]$Message = '') Write-Host $Message } -function info { param([string]$Message) Write-Host " $Message" } -function step { param([string]$Message) Write-Host "`n==> $Message" } -function warn { param([string]$Message) [Console]::Error.WriteLine("WARNING: $Message") } -function die { param([string]$Message) [Console]::Error.WriteLine("ERROR: $Message"); exit 1 } - -# Every parameter is read into a variable here rather than from inside a function. -# A script parameter reached only from a nested scope reads as declared and never used, which is a finding on each one and hides a parameter that genuinely is unused. -$ACTIONS = [ordered]@{ - report = [bool]$Report - upgrade = [bool]$Upgrade - tools = [bool]$Tools - github = [bool]$Github - skills = [bool]$Skills - wsl = [bool]$Wsl - host = [bool]$StandUpHost - dev = [bool]$Dev -} -$WANT_HELP = [bool]$Help -$DRY_RUN = [bool]$DryRun -$ASSUME_YES = [bool]$Yes -$KEEP = [bool]$Keep -$REF = if ($Ref) { $Ref } else { $DEFAULT_REF } - -$MODE = '' -# No placeholder for $DIR here, unlike the four lines around it: PowerShell variable names are case-insensitive, so $DIR and the -Dir parameter $Dir are the same variable, and resetting it here would silently discard whatever -Dir the caller passed before Resolve-Directory ever reads it. -$RESOLVED = '' -$TREE = '' -$PWSH_PATH = '' - -function usage { - # The closing marker of a here-string has to sit at column 0, so this block is deliberately unindented. - Write-Host @' -Usage: bootstrap.ps1 [action] [options] - -Stands a host up: upgrades its packages, installs the host tools, and configures git and GitHub. -Fetches this repository and runs the tooling from that tree, so the tools and the rules that -describe them come from one revision rather than from whatever a host happens to hold. - -Actions, name one, default -Report: - -r, -Report Report what each tool would do, change nothing - -Host Upgrade packages, install the tools, configure git and GitHub, install the skills - -Dev As -Host, and add the tools a development machine needs - -Upgrade Upgrade the packages winget manages, only - -Tools Install the host tools, only - -Github Configure git, the SSH key, and commit signing, only - -Skills Install the fleet skills for the current user, only - -w, -Wsl Report the WSL platform and the distributions installed, only - -h, -Help Show this help - -Options: - -y, -Yes Do not prompt, and pass the same to each tool - -n, -DryRun Print what each step would run, change nothing - -Ref REF Branch, tag, pull request ref, or commit to run from, default main - -Dir PATH Where the tree is extracted, default %LOCALAPPDATA%\host-setup - -Keep Leave the extracted tree in place, which is removed by default - -With no action on a console, the menu asks. With no action and no console, the report runs, since a -redirected run is not a place to answer a question. - -This runs under Windows PowerShell 5.1, the version every fresh Windows host guarantees, and hands -control to PowerShell 7 once it has found or installed it: every script under host-setup\windows -requires it and refuses to run without it. - -Examples: - bootstrap.ps1 Ask what to do - bootstrap.ps1 -Report Report only - bootstrap.ps1 -Host -Yes Stand a host up unattended - bootstrap.ps1 -Ref develop -Report Report using the tooling on develop - bootstrap.ps1 -Tools -DryRun Show what installing the tools would run -'@ -} - -# --- pwsh handoff --- -# -# Everything above this point, and the four functions below, run under Windows PowerShell 5.1: no ternary or null-coalescing operator, no multi-argument Join-Path, nothing newer than that runtime parses. -# Everything past the handoff may use whatever pwsh 7 accepts, though it mostly does not need to. - -function Resolve-Pwsh { - $command = Get-Command pwsh -ErrorAction SilentlyContinue - if ($command) { return $command.Source } - # ProgramFiles(x86) carries no value on a host with no WOW64 layer, and Join-Path on a null path is a terminating error under Set-StrictMode, not an empty match to fall through. - $candidates = @((Join-Path $env:ProgramFiles 'PowerShell\7\pwsh.exe')) - if (${env:ProgramFiles(x86)}) { $candidates += (Join-Path ${env:ProgramFiles(x86)} 'PowerShell\7\pwsh.exe') } - foreach ($candidate in $candidates) { - if (Test-Path $candidate) { return $candidate } - } - return $null -} - -# This is the one place that install-tools.ps1's own rule does not hold: pwsh is deliberately not part of any tool registry, because a host that cannot run these scripts cannot be repaired by them. -# Standing up pwsh is the whole reason this file exists rather than starting from a checkout. -function Install-Pwsh { - if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { - die 'pwsh (PowerShell 7) is not installed, and winget is not on this host to install it. Install "App Installer" from the Microsoft Store, or install PowerShell 7 directly from https://aka.ms/PSWindows, then run this again.' - } - step 'Installing PowerShell 7' - & winget install --id Microsoft.PowerShell --exact --source winget --accept-source-agreements --accept-package-agreements --silent --disable-interactivity | Out-Host - $wingetExit = $LASTEXITCODE - $found = Resolve-Pwsh - if (-not $found) { - # This names winget's own exit code even though pwsh's absence, not the code, is what decides this die: a non-zero code explains why, where "reported installing" alone does not, and winget answering 0 while pwsh is still missing is worth saying too. - die "winget exited $wingetExit installing PowerShell 7, and pwsh could still not be found. Close this console and paste the setup lines again, or install it from https://aka.ms/PSWindows." - } - return $found -} - -# Rebuilds the arguments this process was bound with, since a param() bound script has no raw $args left to forward: -Report arrives as $Report = $true, not as a string in a list. -function Get-ForwardedArgument { - $forward = @() - foreach ($key in $script:SCRIPT_BOUND_PARAMETERS.Keys) { - $value = $script:SCRIPT_BOUND_PARAMETERS[$key] - if ($value -is [switch]) { - if ($value.IsPresent) { $forward += "-$key" } - } else { - $forward += "-$key" - $forward += "$value" - } - } - return , $forward -} - -function Invoke-PwshHandoff { - $pwshPath = Resolve-Pwsh - if (-not $pwshPath) { $pwshPath = Install-Pwsh } - $forward = Get-ForwardedArgument - & $pwshPath -NoProfile -ExecutionPolicy Bypass -File $PSCommandPath @forward - exit $LASTEXITCODE -} - -# --- Fetch --- - -# Resolve the ref to the commit it names, so the run reports a revision rather than a moving name. -# The plain-text accept header returns the commit alone, which keeps this free of a JSON parser on a host that has none. -function Resolve-Ref { - try { - $sha = Invoke-RestMethod -Uri "https://api.github.com/repos/$script:REPO/commits/$script:REF" -Headers @{ Accept = 'application/vnd.github.sha' } -TimeoutSec 15 - } catch { - $sha = $null - } - if ($sha) { - $script:RESOLVED = "$sha".Trim() - return - } - # An unauthenticated request is rate limited per address, so a busy network can lose the lookup while the download itself is fine. - warn "Could not resolve $script:REF to a commit, so this run cannot be attributed to one" - $script:RESOLVED = '' -} - -# The paths this loader creates under DIR, named in one place so the cleanup and the download agree. -# DIR itself is never removed, since -Dir may name a directory the caller owns and put other things in. -function Get-TreePath { Join-Path $script:DIR 'tree' } -function Get-ArchivePath { Join-Path $script:DIR 'tree.tar.gz' } -function Get-MarkerPath { Join-Path (Get-TreePath) '.bootstrap-owned' } - -# A tree carries a marker this loader wrote, and a tree without one is somebody else's. -# DIR is a caller-supplied path, so 'tree' under it is not necessarily ours: pointing -Dir at a directory that already holds one would otherwise have this remove it, both before extracting and again on exit. -function Test-TreeOwnership { Test-Path (Get-MarkerPath) } - -# Refuses to remove a tree this run did not create, rather than trusting the name. -function Remove-Tree { - $tree = Get-TreePath - if (-not (Test-Path $tree)) { return } - if (-not (Test-TreeOwnership)) { die "$tree exists and this loader did not create it, so it will not be removed. Choose another -Dir." } - Remove-Item -Recurse -Force $tree -} - -function Get-Tree { - $archive = Get-ArchivePath - $want = if ($script:RESOLVED) { $script:RESOLVED } else { $script:REF } - - step "Fetching $script:REPO at $script:REF" - if ($script:RESOLVED) { info "Commit: $script:RESOLVED" } - - New-Item -ItemType Directory -Path $script:DIR -Force | Out-Null - try { - Invoke-WebRequest -UseBasicParsing -Uri "https://codeload.github.com/$script:REPO/tar.gz/$want" -OutFile $archive -TimeoutSec 120 - } catch { - die "Could not download $script:REPO at $script:REF. Check the ref exists and that this host reaches codeload.github.com." - } - - # The archive holds one top-level directory named for the repository and the revision. - # Extracting into a directory of our own keeps a second run from reading the first one's tree. - $tree = Get-TreePath - Remove-Tree - New-Item -ItemType Directory -Path $tree -Force | Out-Null - New-Item -ItemType File -Path (Get-MarkerPath) -Force | Out-Null - & tar -xzf $archive -C $tree --strip-components=1 - if ($LASTEXITCODE -ne 0) { die 'Could not extract the downloaded archive' } - Remove-Item -Force $archive -ErrorAction SilentlyContinue - - $script:TREE = $tree - info "Extracted to $script:TREE" -} - -# A tree that is not ours was already refused where it mattered, at the download. -# Refusing again from here would print the same error a second time, after the one that actually stopped the run. -# Removes what this run created rather than what it finished, because TREE is set only once extraction has succeeded. -# A failed extract leaves both the archive and a part-written tree, so keying this on TREE left a tarball in the cache on every failed attempt. -function Invoke-Cleanup { - if ($script:KEEP) { return } - $tree = Get-TreePath - if ((Test-Path $tree) -and -not (Test-TreeOwnership)) { - Remove-Item -Force (Get-ArchivePath) -ErrorAction SilentlyContinue - return - } - Remove-Tree - Remove-Item -Force (Get-ArchivePath) -ErrorAction SilentlyContinue -} - -# --- Handoff --- - -# Every tool runs from inside the fetched tree, and this is the only place a path inside it is named. -# Written as one interpolated, forward-slashed string against the bare $TREE rather than $script:TREE or Join-Path, so this loader's one entry point into the tree reads as the literal pattern the Linux loader is checked by, and stays checkable by that same pattern. -# A read resolves $TREE up to script scope on its own, and only a write needs the script: prefix, which is why the assignment in Get-Tree still carries it. -function Invoke-Tool { - param([Parameter(Mandatory)][string]$Tool, [switch]$ToleratesFailure, [Parameter(ValueFromRemainingArguments)][string[]]$Arguments) - - $path = "$TREE/host-setup/windows/$Tool" - if (-not (Test-Path $path)) { - die "The fetched tree carries no $Tool at host-setup/windows, so this ref is not one to bootstrap from" - } - - $flags = @() - if ($script:ASSUME_YES) { $flags += '-Yes' } - if ($script:DRY_RUN) { $flags += '-DryRun' } - - & $script:PWSH_PATH -NoProfile -ExecutionPolicy Bypass -File $path @Arguments @flags - if ($LASTEXITCODE -ne 0 -and -not $ToleratesFailure) { die "$Tool exited $LASTEXITCODE" } -} - -function Show-Report { - Invoke-Tool -Tool 'upgrade-host.ps1' -Arguments '-Status' - Invoke-Tool -Tool 'install-tools.ps1' -Arguments '-Report' - Invoke-Tool -Tool 'setup-github.ps1' -Arguments '-Status' - # Tolerated rather than fatal, since a missing install is a finding for a report to name and not a reason to stop naming the rest. - Invoke-Tool -Tool 'install-skills.ps1' -ToleratesFailure -Arguments '-Report' - if ($LASTEXITCODE -ne 0) { info 'The fleet skills install is missing or stale, and -Host or -Skills lands it' } -} - -# The order is fixed rather than chosen. -# Packages come first so install-tools.ps1 and setup-github.ps1 act on a host winget has just brought current, and GitHub comes last because it is the only step that waits on a person in a browser. -# The skills step runs after the tools, because install-tools.ps1 provides the interpreter it needs. -function Invoke-StandUp { - # Named Kind rather than Profile, which is PowerShell's own automatic variable for the current user's profile script. - param([string]$Kind) - - Invoke-Tool -Tool 'upgrade-host.ps1' -Arguments '-Packages' - if ($Kind -eq 'dev') { - Invoke-Tool -Tool 'install-tools.ps1' -Arguments '-Install', '-Optional' - } else { - Invoke-Tool -Tool 'install-tools.ps1' -Arguments '-Install' - } - Invoke-Tool -Tool 'setup-github.ps1' -Arguments '-Configure' - Invoke-Tool -Tool 'install-skills.ps1' -} - -# Names the host in the menu heading. -function Get-HostDescription { - try { - return (Get-CimInstance Win32_OperatingSystem -ErrorAction Stop).Caption - } catch { - return 'this host' - } -} - -# Both are checked because a scheduled task reports one and not the other, and either alone misses a case. -# The ISE is checked apart, because it answers both of those as interactive and then blocks on a Read-Host it does not render usably. -function Test-Interactive { - if (-not [Environment]::UserInteractive -or [Console]::IsInputRedirected) { return $false } - if ($Host.Name -eq 'Windows PowerShell ISE Host') { return $false } - return $true -} - -function Show-Menu { - log "Standing up $(Get-HostDescription)" - log '' - log ' 1 Report only, change nothing' - log ' 2 Upgrade the packages winget manages' - log ' 3 Install the host tools' - log ' 4 Configure git and GitHub' - log ' 5 Install the fleet skills' - log ' 6 Report the WSL platform and the distributions installed' - log ' 7 All of the above but WSL, which is a host stood up' - log ' 8 All of the above but WSL, plus the development tools' - log ' q Quit' - log '' - - $choice = Read-Host 'Choose' - switch ($choice) { - '1' { $script:MODE = 'report' } - '2' { $script:MODE = 'upgrade' } - '3' { $script:MODE = 'tools' } - '4' { $script:MODE = 'github' } - '5' { $script:MODE = 'skills' } - '6' { $script:MODE = 'wsl' } - '7' { $script:MODE = 'host' } - '8' { $script:MODE = 'dev' } - 'q' { exit 0 } - 'Q' { exit 0 } - default { die 'Not one of the choices' } - } -} - -# --- Entry --- - -# Windows has shipped tar.exe under %SystemRoot%\System32 since Windows 10 1803 and Windows Server 2019, and it reads a .tar.gz archive directly. -# That is why this loader does not reach for Expand-Archive, which cannot. -function Test-Prerequisite { - if (-not (Get-Command tar -ErrorAction SilentlyContinue)) { - die 'tar.exe not found under %SystemRoot%\System32. This assumes Windows 10 1803, Windows Server 2019, or later, all of which ship it.' - } -} - -# An absolute path, and never a drive root, since everything below it is created and removed under it. -function Resolve-Directory { - if (-not $script:Dir) { return (Join-Path $env:LOCALAPPDATA 'host-setup') } - if (-not [IO.Path]::IsPathRooted($script:Dir)) { die "-Dir takes an absolute path, and `"$($script:Dir)`" is relative" } - $trimmed = $script:Dir.TrimEnd('\', '/') - if ((-not $trimmed) -or ($trimmed -match '^[A-Za-z]:$')) { die '-Dir may not be a drive root' } - return $trimmed -} - -# PowerShell records which switches were given and not the order they came in, so two actions is a refusal rather than the last one winning. -# Unlike the four scripts this loader drives, zero given is not this loader's own default: main tells the menu and the piped-in report apart, the way bootstrap.sh's own entry point does. -function Resolve-Mode { - $given = @($script:ACTIONS.Keys | Where-Object { $script:ACTIONS[$_] }) - if ($given.Count -gt 1) { die "More than one action given ($($given -join ', ')), name one" } - if ($given.Count -eq 0) { return '' } - return $given[0] -} - -function main { - if ($script:WANT_HELP) { usage; exit 0 } - - if ($PSVersionTable.PSVersion.Major -lt 7) { - Invoke-PwshHandoff - } - - # Reached only under a confirmed pwsh 7, either started that way or handed off to above. - $script:PWSH_PATH = (Get-Process -Id $PID).Path - Test-Prerequisite - $script:DIR = Resolve-Directory - $script:MODE = Resolve-Mode - - # A run with no action and no console reports rather than guessing, which is what a redirected run is. - # The remedy is printed rather than assumed, since somebody reaching this has just pasted a one-line install. - if (-not $script:MODE) { - if (Test-Interactive) { - Show-Menu - } else { - $script:MODE = 'report' - warn 'No action given and no console to ask on, so this is a report' - info 'Download the file and run it, rather than piping it, to reach the menu:' - info ' [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12' - info " Invoke-WebRequest -UseBasicParsing -Uri https://raw.githubusercontent.com/$script:REPO/$script:DEFAULT_REF/host-setup/bootstrap.ps1 -OutFile bootstrap.ps1" - info ' powershell -ExecutionPolicy Bypass -File bootstrap.ps1' - } - } - - try { - Resolve-Ref - # The commit the resolve produced is handed to the skills installer, since the tarball tree it runs from has no .git to answer for it. - $env:SKILLS_SOURCE_COMMIT = $script:RESOLVED - Get-Tree - switch ($script:MODE) { - 'report' { Show-Report } - 'upgrade' { Invoke-Tool -Tool 'upgrade-host.ps1' -Arguments '-Packages' } - 'tools' { Invoke-Tool -Tool 'install-tools.ps1' -Arguments '-Install' } - 'github' { Invoke-Tool -Tool 'setup-github.ps1' -Arguments '-Configure' } - 'skills' { Invoke-Tool -Tool 'install-skills.ps1' } - # Only setup-wsl.ps1's -Status runs here: its -Install needs a distribution name, which no flag here collects, so choosing a default distro nobody asked for is exactly what -Wsl staying out of -Host and -Dev already exists to avoid. - # Installing one by name is a checkout away, once this run has fetched it. - 'wsl' { Invoke-Tool -Tool 'setup-wsl.ps1' -Arguments '-Status' } - 'host' { Invoke-StandUp -Kind 'host' } - 'dev' { Invoke-StandUp -Kind 'dev' } - } - } finally { - Invoke-Cleanup - } - - step 'Done' - if ($script:KEEP) { info "The fetched tree is at $script:TREE" } -} - -main +# Stands a host up from nothing, by fetching this repository and handing control to the host tooling inside it. +# It is the one file fetched on its own, because a host with no git and no checkout is what it exists to fix. +# It reads no payload, no table, and no sibling module: it obtains a tree and runs one entry point inside that tree. +# +# A tarball rather than a clone, because a clone needs git on a host that may not have it, and because a tarball of a resolved commit cannot be stale. +# The commit it resolved is printed before anything runs, so a run says which revision of the fleet's tooling it used. +# +# An unverified loader is worse than none, which is why this one does more than its Linux peer before it trusts anything. +# It pins TLS 1.2 itself rather than assume a fresh host's default reaches GitHub, it checks a fetched tree for the marker it wrote before removing anything under -Dir, and it hands off to PowerShell 7 explicitly rather than assume the console it started in already carries it. +# +# Windows PowerShell 5.1 is the one shell a fresh Windows host guarantees, the way bash is guaranteed on a fresh Debian host, so everything up to and including finding or installing PowerShell 7 is written to run under it. +# Once pwsh is confirmed, this file hands the rest of the run to itself under pwsh, and every script under host-setup/windows it goes on to drive requires that version too. + +# The Host action binds to StandUpHost rather than Host, because Host is PowerShell's own automatic variable for the host program ($Host.Name, $Host.UI), and a parameter named Host would shadow it and fail PSAvoidAssignmentToAutomaticVariable. +# The alias keeps -Host as the spelling a caller types, while leaving the automatic variable free for the console-detection checks below. +[CmdletBinding()] +param( + [Alias('r')][switch]$Report, + [Alias('Host')][switch]$StandUpHost, + [switch]$Dev, + [switch]$Upgrade, + [switch]$Tools, + [switch]$Github, + [switch]$Skills, + [Alias('w')][switch]$Wsl, + [Alias('n')][switch]$DryRun, + [Alias('y')][switch]$Yes, + [string]$Ref, + [string]$Dir, + [switch]$Keep, + [Alias('h')][switch]$Help +) + +# Captured before anything else touches scope, so the pwsh handoff below can rebuild the argument list this process was bound with, rather than reaching for an automatic variable from inside a nested function. +$SCRIPT_BOUND_PARAMETERS = $PSBoundParameters + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +# A non-zero exit from winget, tar or a driven script is an answer here rather than a failure. +# Setting this keeps a profile that turned it on from turning every read into a terminating error. +$PSNativeCommandUseErrorActionPreference = $false + +# A fresh Windows host's default TLS floor can predate 1.2, which raw.githubusercontent.com and the GitHub API both require. +# Set once, unconditionally, since it is a no-op where the runtime already defaults higher. +[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + +$REPO = 'ptr727/ProjectTemplate' +$DEFAULT_REF = 'main' + +# --- Output --- + +function log { param([string]$Message = '') Write-Host $Message } +function info { param([string]$Message) Write-Host " $Message" } +function step { param([string]$Message) Write-Host "`n==> $Message" } +function warn { param([string]$Message) [Console]::Error.WriteLine("WARNING: $Message") } +function die { param([string]$Message) [Console]::Error.WriteLine("ERROR: $Message"); exit 1 } + +# Every parameter is read into a variable here rather than from inside a function. +# A script parameter reached only from a nested scope reads as declared and never used, which is a finding on each one and hides a parameter that genuinely is unused. +$ACTIONS = [ordered]@{ + report = [bool]$Report + upgrade = [bool]$Upgrade + tools = [bool]$Tools + github = [bool]$Github + skills = [bool]$Skills + wsl = [bool]$Wsl + host = [bool]$StandUpHost + dev = [bool]$Dev +} +$WANT_HELP = [bool]$Help +$DRY_RUN = [bool]$DryRun +$ASSUME_YES = [bool]$Yes +$KEEP = [bool]$Keep +$REF = if ($Ref) { $Ref } else { $DEFAULT_REF } + +$MODE = '' +# No placeholder for $DIR here, unlike the four lines around it: PowerShell variable names are case-insensitive, so $DIR and the -Dir parameter $Dir are the same variable, and resetting it here would silently discard whatever -Dir the caller passed before Resolve-Directory ever reads it. +$RESOLVED = '' +$TREE = '' +$PWSH_PATH = '' + +function usage { + # The closing marker of a here-string has to sit at column 0, so this block is deliberately unindented. + Write-Host @' +Usage: bootstrap.ps1 [action] [options] + +Stands a host up: upgrades its packages, installs the host tools, and configures git and GitHub. +Fetches this repository and runs the tooling from that tree, so the tools and the rules that +describe them come from one revision rather than from whatever a host happens to hold. + +Actions, name one, default -Report: + -r, -Report Report what each tool would do, change nothing + -Host Upgrade packages, install the tools, configure git and GitHub, install the skills + -Dev As -Host, and add the tools a development machine needs + -Upgrade Upgrade the packages winget manages, only + -Tools Install the host tools, only + -Github Configure git, the SSH key, and commit signing, only + -Skills Install the fleet skills for the current user, only + -w, -Wsl Report the WSL platform and the distributions installed, only + -h, -Help Show this help + +Options: + -y, -Yes Do not prompt, and pass the same to each tool + -n, -DryRun Print what each step would run, change nothing + -Ref REF Branch, tag, pull request ref, or commit to run from, default main + -Dir PATH Where the tree is extracted, default %LOCALAPPDATA%\host-setup + -Keep Leave the extracted tree in place, which is removed by default + +With no action on a console, the menu asks. With no action and no console, the report runs, since a +redirected run is not a place to answer a question. + +This runs under Windows PowerShell 5.1, the version every fresh Windows host guarantees, and hands +control to PowerShell 7 once it has found or installed it: every script under host-setup\windows +requires it and refuses to run without it. + +Examples: + bootstrap.ps1 Ask what to do + bootstrap.ps1 -Report Report only + bootstrap.ps1 -Host -Yes Stand a host up unattended + bootstrap.ps1 -Ref develop -Report Report using the tooling on develop + bootstrap.ps1 -Tools -DryRun Show what installing the tools would run +'@ +} + +# --- pwsh handoff --- +# +# Everything above this point, and the four functions below, run under Windows PowerShell 5.1: no ternary or null-coalescing operator, no multi-argument Join-Path, nothing newer than that runtime parses. +# Everything past the handoff may use whatever pwsh 7 accepts, though it mostly does not need to. + +function Resolve-Pwsh { + $command = Get-Command pwsh -ErrorAction SilentlyContinue + if ($command) { return $command.Source } + # ProgramFiles(x86) carries no value on a host with no WOW64 layer, and Join-Path on a null path is a terminating error under Set-StrictMode, not an empty match to fall through. + $candidates = @((Join-Path $env:ProgramFiles 'PowerShell\7\pwsh.exe')) + if (${env:ProgramFiles(x86)}) { $candidates += (Join-Path ${env:ProgramFiles(x86)} 'PowerShell\7\pwsh.exe') } + foreach ($candidate in $candidates) { + if (Test-Path $candidate) { return $candidate } + } + return $null +} + +# This is the one place that install-tools.ps1's own rule does not hold: pwsh is deliberately not part of any tool registry, because a host that cannot run these scripts cannot be repaired by them. +# Standing up pwsh is the whole reason this file exists rather than starting from a checkout. +function Install-Pwsh { + if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { + die 'pwsh (PowerShell 7) is not installed, and winget is not on this host to install it. Install "App Installer" from the Microsoft Store, or install PowerShell 7 directly from https://aka.ms/PSWindows, then run this again.' + } + step 'Installing PowerShell 7' + & winget install --id Microsoft.PowerShell --exact --source winget --accept-source-agreements --accept-package-agreements --silent --disable-interactivity | Out-Host + $wingetExit = $LASTEXITCODE + $found = Resolve-Pwsh + if (-not $found) { + # This names winget's own exit code even though pwsh's absence, not the code, is what decides this die: a non-zero code explains why, where "reported installing" alone does not, and winget answering 0 while pwsh is still missing is worth saying too. + die "winget exited $wingetExit installing PowerShell 7, and pwsh could still not be found. Close this console and paste the setup lines again, or install it from https://aka.ms/PSWindows." + } + return $found +} + +# Rebuilds the arguments this process was bound with, since a param() bound script has no raw $args left to forward: -Report arrives as $Report = $true, not as a string in a list. +function Get-ForwardedArgument { + $forward = @() + foreach ($key in $script:SCRIPT_BOUND_PARAMETERS.Keys) { + $value = $script:SCRIPT_BOUND_PARAMETERS[$key] + if ($value -is [switch]) { + if ($value.IsPresent) { $forward += "-$key" } + } else { + $forward += "-$key" + $forward += "$value" + } + } + return , $forward +} + +function Invoke-PwshHandoff { + $pwshPath = Resolve-Pwsh + if (-not $pwshPath) { $pwshPath = Install-Pwsh } + $forward = Get-ForwardedArgument + & $pwshPath -NoProfile -ExecutionPolicy Bypass -File $PSCommandPath @forward + exit $LASTEXITCODE +} + +# --- Fetch --- + +# Resolve the ref to the commit it names, so the run reports a revision rather than a moving name. +# The plain-text accept header returns the commit alone, which keeps this free of a JSON parser on a host that has none. +function Resolve-Ref { + try { + $sha = Invoke-RestMethod -Uri "https://api.github.com/repos/$script:REPO/commits/$script:REF" -Headers @{ Accept = 'application/vnd.github.sha' } -TimeoutSec 15 + } catch { + $sha = $null + } + if ($sha) { + $script:RESOLVED = "$sha".Trim() + return + } + # An unauthenticated request is rate limited per address, so a busy network can lose the lookup while the download itself is fine. + warn "Could not resolve $script:REF to a commit, so this run cannot be attributed to one" + $script:RESOLVED = '' +} + +# The paths this loader creates under DIR, named in one place so the cleanup and the download agree. +# DIR itself is never removed, since -Dir may name a directory the caller owns and put other things in. +function Get-TreePath { Join-Path $script:DIR 'tree' } +function Get-ArchivePath { Join-Path $script:DIR 'tree.tar.gz' } +function Get-MarkerPath { Join-Path (Get-TreePath) '.bootstrap-owned' } + +# A tree carries a marker this loader wrote, and a tree without one is somebody else's. +# DIR is a caller-supplied path, so 'tree' under it is not necessarily ours: pointing -Dir at a directory that already holds one would otherwise have this remove it, both before extracting and again on exit. +function Test-TreeOwnership { Test-Path (Get-MarkerPath) } + +# Refuses to remove a tree this run did not create, rather than trusting the name. +function Remove-Tree { + $tree = Get-TreePath + if (-not (Test-Path $tree)) { return } + if (-not (Test-TreeOwnership)) { die "$tree exists and this loader did not create it, so it will not be removed. Choose another -Dir." } + Remove-Item -Recurse -Force $tree +} + +function Get-Tree { + $archive = Get-ArchivePath + $want = if ($script:RESOLVED) { $script:RESOLVED } else { $script:REF } + + step "Fetching $script:REPO at $script:REF" + if ($script:RESOLVED) { info "Commit: $script:RESOLVED" } + + New-Item -ItemType Directory -Path $script:DIR -Force | Out-Null + try { + Invoke-WebRequest -UseBasicParsing -Uri "https://codeload.github.com/$script:REPO/tar.gz/$want" -OutFile $archive -TimeoutSec 120 + } catch { + die "Could not download $script:REPO at $script:REF. Check the ref exists and that this host reaches codeload.github.com." + } + + # The archive holds one top-level directory named for the repository and the revision. + # Extracting into a directory of our own keeps a second run from reading the first one's tree. + $tree = Get-TreePath + Remove-Tree + New-Item -ItemType Directory -Path $tree -Force | Out-Null + New-Item -ItemType File -Path (Get-MarkerPath) -Force | Out-Null + & tar -xzf $archive -C $tree --strip-components=1 + if ($LASTEXITCODE -ne 0) { die 'Could not extract the downloaded archive' } + Remove-Item -Force $archive -ErrorAction SilentlyContinue + + $script:TREE = $tree + info "Extracted to $script:TREE" +} + +# A tree that is not ours was already refused where it mattered, at the download. +# Refusing again from here would print the same error a second time, after the one that actually stopped the run. +# Removes what this run created rather than what it finished, because TREE is set only once extraction has succeeded. +# A failed extract leaves both the archive and a part-written tree, so keying this on TREE left a tarball in the cache on every failed attempt. +function Invoke-Cleanup { + if ($script:KEEP) { return } + $tree = Get-TreePath + if ((Test-Path $tree) -and -not (Test-TreeOwnership)) { + Remove-Item -Force (Get-ArchivePath) -ErrorAction SilentlyContinue + return + } + Remove-Tree + Remove-Item -Force (Get-ArchivePath) -ErrorAction SilentlyContinue +} + +# --- Handoff --- + +# Every tool runs from inside the fetched tree, and this is the only place a path inside it is named. +# Written as one interpolated, forward-slashed string against the bare $TREE rather than $script:TREE or Join-Path, so this loader's one entry point into the tree reads as the literal pattern the Linux loader is checked by, and stays checkable by that same pattern. +# A read resolves $TREE up to script scope on its own, and only a write needs the script: prefix, which is why the assignment in Get-Tree still carries it. +function Invoke-Tool { + param([Parameter(Mandatory)][string]$Tool, [switch]$ToleratesFailure, [Parameter(ValueFromRemainingArguments)][string[]]$Arguments) + + $path = "$TREE/host-setup/windows/$Tool" + if (-not (Test-Path $path)) { + die "The fetched tree carries no $Tool at host-setup/windows, so this ref is not one to bootstrap from" + } + + $flags = @() + if ($script:ASSUME_YES) { $flags += '-Yes' } + if ($script:DRY_RUN) { $flags += '-DryRun' } + + & $script:PWSH_PATH -NoProfile -ExecutionPolicy Bypass -File $path @Arguments @flags + if ($LASTEXITCODE -ne 0 -and -not $ToleratesFailure) { die "$Tool exited $LASTEXITCODE" } +} + +function Show-Report { + Invoke-Tool -Tool 'upgrade-host.ps1' -Arguments '-Status' + Invoke-Tool -Tool 'install-tools.ps1' -Arguments '-Report' + Invoke-Tool -Tool 'setup-github.ps1' -Arguments '-Status' + # Tolerated rather than fatal, since a missing install is a finding for a report to name and not a reason to stop naming the rest. + Invoke-Tool -Tool 'install-skills.ps1' -ToleratesFailure -Arguments '-Report' + if ($LASTEXITCODE -ne 0) { info 'The fleet skills install is missing or stale, and -Host or -Skills lands it' } +} + +# The order is fixed rather than chosen. +# Packages come first so install-tools.ps1 and setup-github.ps1 act on a host winget has just brought current, and GitHub comes last because it is the only step that waits on a person in a browser. +# The skills step runs after the tools, because install-tools.ps1 provides the interpreter it needs. +function Invoke-StandUp { + # Named Kind rather than Profile, which is PowerShell's own automatic variable for the current user's profile script. + param([string]$Kind) + + Invoke-Tool -Tool 'upgrade-host.ps1' -Arguments '-Packages' + if ($Kind -eq 'dev') { + Invoke-Tool -Tool 'install-tools.ps1' -Arguments '-Install', '-Optional' + } else { + Invoke-Tool -Tool 'install-tools.ps1' -Arguments '-Install' + } + Invoke-Tool -Tool 'setup-github.ps1' -Arguments '-Configure' + Invoke-Tool -Tool 'install-skills.ps1' +} + +# Names the host in the menu heading. +function Get-HostDescription { + try { + return (Get-CimInstance Win32_OperatingSystem -ErrorAction Stop).Caption + } catch { + return 'this host' + } +} + +# Both are checked because a scheduled task reports one and not the other, and either alone misses a case. +# The ISE is checked apart, because it answers both of those as interactive and then blocks on a Read-Host it does not render usably. +function Test-Interactive { + if (-not [Environment]::UserInteractive -or [Console]::IsInputRedirected) { return $false } + if ($Host.Name -eq 'Windows PowerShell ISE Host') { return $false } + return $true +} + +function Show-Menu { + log "Standing up $(Get-HostDescription)" + log '' + log ' 1 Report only, change nothing' + log ' 2 Upgrade the packages winget manages' + log ' 3 Install the host tools' + log ' 4 Configure git and GitHub' + log ' 5 Install the fleet skills' + log ' 6 Report the WSL platform and the distributions installed' + log ' 7 All of the above but WSL, which is a host stood up' + log ' 8 All of the above but WSL, plus the development tools' + log ' q Quit' + log '' + + $choice = Read-Host 'Choose' + switch ($choice) { + '1' { $script:MODE = 'report' } + '2' { $script:MODE = 'upgrade' } + '3' { $script:MODE = 'tools' } + '4' { $script:MODE = 'github' } + '5' { $script:MODE = 'skills' } + '6' { $script:MODE = 'wsl' } + '7' { $script:MODE = 'host' } + '8' { $script:MODE = 'dev' } + 'q' { exit 0 } + 'Q' { exit 0 } + default { die 'Not one of the choices' } + } +} + +# --- Entry --- + +# Windows has shipped tar.exe under %SystemRoot%\System32 since Windows 10 1803 and Windows Server 2019, and it reads a .tar.gz archive directly. +# That is why this loader does not reach for Expand-Archive, which cannot. +function Test-Prerequisite { + if (-not (Get-Command tar -ErrorAction SilentlyContinue)) { + die 'tar.exe not found under %SystemRoot%\System32. This assumes Windows 10 1803, Windows Server 2019, or later, all of which ship it.' + } +} + +# An absolute path, and never a drive root, since everything below it is created and removed under it. +function Resolve-Directory { + if (-not $script:Dir) { return (Join-Path $env:LOCALAPPDATA 'host-setup') } + if (-not [IO.Path]::IsPathRooted($script:Dir)) { die "-Dir takes an absolute path, and `"$($script:Dir)`" is relative" } + $trimmed = $script:Dir.TrimEnd('\', '/') + if ((-not $trimmed) -or ($trimmed -match '^[A-Za-z]:$')) { die '-Dir may not be a drive root' } + return $trimmed +} + +# PowerShell records which switches were given and not the order they came in, so two actions is a refusal rather than the last one winning. +# Unlike the four scripts this loader drives, zero given is not this loader's own default: main tells the menu and the piped-in report apart, the way bootstrap.sh's own entry point does. +function Resolve-Mode { + $given = @($script:ACTIONS.Keys | Where-Object { $script:ACTIONS[$_] }) + if ($given.Count -gt 1) { die "More than one action given ($($given -join ', ')), name one" } + if ($given.Count -eq 0) { return '' } + return $given[0] +} + +function main { + if ($script:WANT_HELP) { usage; exit 0 } + + if ($PSVersionTable.PSVersion.Major -lt 7) { + Invoke-PwshHandoff + } + + # Reached only under a confirmed pwsh 7, either started that way or handed off to above. + $script:PWSH_PATH = (Get-Process -Id $PID).Path + Test-Prerequisite + $script:DIR = Resolve-Directory + $script:MODE = Resolve-Mode + + # A run with no action and no console reports rather than guessing, which is what a redirected run is. + # The remedy is printed rather than assumed, since somebody reaching this has just pasted a one-line install. + if (-not $script:MODE) { + if (Test-Interactive) { + Show-Menu + } else { + $script:MODE = 'report' + warn 'No action given and no console to ask on, so this is a report' + info 'Download the file and run it, rather than piping it, to reach the menu:' + info ' [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12' + info " Invoke-WebRequest -UseBasicParsing -Uri https://raw.githubusercontent.com/$script:REPO/$script:DEFAULT_REF/host-setup/bootstrap.ps1 -OutFile bootstrap.ps1" + info ' powershell -ExecutionPolicy Bypass -File bootstrap.ps1' + } + } + + try { + Resolve-Ref + # The commit the resolve produced is handed to the skills installer, since the tarball tree it runs from has no .git to answer for it. + $env:SKILLS_SOURCE_COMMIT = $script:RESOLVED + Get-Tree + switch ($script:MODE) { + 'report' { Show-Report } + 'upgrade' { Invoke-Tool -Tool 'upgrade-host.ps1' -Arguments '-Packages' } + 'tools' { Invoke-Tool -Tool 'install-tools.ps1' -Arguments '-Install' } + 'github' { Invoke-Tool -Tool 'setup-github.ps1' -Arguments '-Configure' } + 'skills' { Invoke-Tool -Tool 'install-skills.ps1' } + # Only setup-wsl.ps1's -Status runs here: its -Install needs a distribution name, which no flag here collects, so choosing a default distro nobody asked for is exactly what -Wsl staying out of -Host and -Dev already exists to avoid. + # Installing one by name is a checkout away, once this run has fetched it. + 'wsl' { Invoke-Tool -Tool 'setup-wsl.ps1' -Arguments '-Status' } + 'host' { Invoke-StandUp -Kind 'host' } + 'dev' { Invoke-StandUp -Kind 'dev' } + } + } finally { + Invoke-Cleanup + } + + step 'Done' + if ($script:KEEP) { info "The fetched tree is at $script:TREE" } +} + +main diff --git a/host-setup/linux/README.md b/host-setup/linux/README.md index 4b72d5f2..f3e68779 100644 --- a/host-setup/linux/README.md +++ b/host-setup/linux/README.md @@ -1,161 +1,161 @@ -# Linux Host Setup - -The tooling that makes a Debian or Ubuntu based host satisfy the contract in [`docs/host-setup.md`][host-setup], Proxmox and WSL included. That document is the contract, meaning which tools a host must provide and why each floor exists. This directory is how a Linux host comes to satisfy it. - -## What Is Here - -- [`install-tools.sh`][install-tools] installs and upgrades the host tools. It reports what each is installed at, what upstream carries, where it comes from, and its status. -- [`upgrade-host.sh`][upgrade-host] upgrades the packages of the current release. Moving to the next release is a separate action behind its own flag. -- [`setup-github.sh`][setup-github] configures the SSH key, git, and commit signing. It checks both key registrations against what GitHub publishes. -- [`install-skills.sh`][install-skills] drives the hub's skills installer at [`scripts/skills_install.py`][skills-install] from this tree. - -Each runs on its own, and each takes `--help`. - -```shell -host-setup/linux/install-tools.sh # report -host-setup/linux/install-tools.sh --install -host-setup/linux/upgrade-host.sh --status -host-setup/linux/setup-github.sh --status -host-setup/linux/install-skills.sh --report -``` - -Each script is LF with a shebang, and its executable bit is tracked in git. A fresh checkout therefore runs each without a `bash` prefix. That is the Linux form of "this will run", and [`scripts/tests/test_bootstrap.py`][test-bootstrap] asserts both. - -## Requirements - -**A Debian or Ubuntu based host, identified from `/etc/os-release`.** A distribution that is neither but declares `ID_LIKE` debian is treated as Debian, with a warning that it is untested. Proxmox reports itself as its Debian base, so it needs no case of its own. `install-tools.sh` and `upgrade-host.sh` refuse anything else. `setup-github.sh` runs anywhere, and stops only when a missing prerequisite needs apt to install it. - -**Root or sudo.** Nothing here elevates wholesale. Each script runs as the caller and puts `sudo` in front of only the commands that change the host. Every read and all file staging stay unprivileged. A run that is not root and finds no `sudo` refuses up front rather than failing partway. - -**A terminal, or `--yes`.** A run with no terminal on standard input and no `--yes` refuses to change the host. A scheduled run therefore cannot hang on a prompt nobody answers. On `upgrade-host.sh`, `--yes` also keeps the installed configuration file on a packaging conflict. An unattended run has nobody to answer dpkg's prompt, and a replaced config is the harder half to notice afterwards. - -`curl` is not a requirement, it is a managed prerequisite. A minimal image carries none, so every upstream read is guarded. An install run puts `ca-certificates`, `curl`, `gnupg`, and `gpgv` in place before the first tool. - -## Why There Are Three Kinds of Source - -The Windows registry has one source because `winget` tracks upstream. Here the distro package trails upstream on `gh`, on `node`, and on `uv`, so a tool comes from whichever source keeps up: - -- **The distro**, for `git` and `python`, where apt's own package is current enough. -- **An upstream apt repository**, for `gh`, `node`, and `docker`, and for `dotnet` as a fallback, where upstream publishes one. -- **A released binary into `/usr/local/bin`**, for `jq`, `uv`, and `git-restore-mtime`, where upstream publishes no repository. - -No version is written into the script. Each upstream is asked what it carries now, so the script does not go stale between releases. Every step is idempotent, so a keyring or sources file is written only when its content differs. A re-run repairs drift rather than assuming a clean host. - -**A keyring is proved, not trusted.** Before a fetched signing key is installed, `gpgv` checks that it signs the repository's own `InRelease` metadata. An upstream that rotates or adds a key breaks a pinned fingerprint list but not this check. A host where the check cannot run stops rather than trusting the download. A released binary is checked against the sha256 list its upstream publishes beside it, for the same reason. - -**Keyrings land in `/etc/apt/keyrings`, and sources land as deb822 files in `/etc/apt/sources.list.d`.** A predecessor in the old location or the old one-line format is removed first, so apt never reads the same repository twice. - -## PATH and Shadowing - -`/usr/local/bin` precedes `/usr/bin`, which is why the distro's `jq` can stay installed and stay shadowed. The upstream binary wins without removing a package something else may depend on. - -The hazard runs the other way too. A copy of `jq`, `uv`, or `git-restore-mtime` sitting earlier on `PATH` keeps answering after this script installs a newer one. That reads as an upgrade that did not take, so the report names such a shadow. `--upgrade` removes it after a prompt. `--install` removes it only when no managed copy exists yet, since removing a newer shadow would downgrade what `PATH` resolves to. A file a distro package owns is never removed, because deleting it would desync dpkg's database from the filesystem. The remedy named there is the `PATH` order itself. The removal loops, since `PATH` can stack more than one shadow ahead of `/usr/local/bin`. A relative `PATH` entry is never trusted as a shadow at all. - -## What the Report Says - -A report changes nothing and reads the apt cache as it stands. An available version is therefore as current as the last `apt update`. Versions compare like with like: apt versions for an apt managed tool, upstream versions for a standalone binary. `docker` is read from the CLI rather than from the `docker-ce` package. On a WSL distribution, Docker Desktop's integration is a working `docker` with no apt package behind it. Its target is stripped of the epoch and packaging revision for the same like-with-like reason. - -`unmanaged` means the tool is installed from the distro while its upstream repository is unconfigured. The one thing the report must not say is that such a tool is current against the distro's own version. The Windows report uses the same word for a different mechanism, a tool on `PATH` that `winget` knows no package for. - -An install or upgrade collects a tool whose install fails and carries on, so one failure does not strand the rest of the run. A refusal is different and ends the run. An unverifiable keyring, a checksum mismatch, or a declined prompt stops everything, because continuing past one would install something nobody vouched for. - -## Docker, node, and dotnet - -**Inside a WSL distribution, docker comes only from Docker Desktop's own WSL integration, never from installing `docker-ce`.** A native install would run a second engine beside Desktop's. `--install` and `--upgrade` therefore always skip it there and point at Docker Desktop's settings instead. The skip counts as success only where `docker` already answers, so a run cannot exit clean having found nothing working. On a native host, the conflicting packages Docker's own uninstall list names are removed first. Non-root use (`usermod -aG docker`) is left to the operator, as a group choice rather than a question of presence. - -**Installing `node` displaces distro packages.** The upstream package carries `npm` itself and conflicts with the distro's `npm` and `nodejs-doc`. The script asks apt what it would remove and puts that list in front of the operator first. Asking apt beats naming the conflicts here, because the conflict set belongs to the upstream package and changes without notice. The major line installed is whatever upstream currently marks LTS, read from its release index at run time. - -**For `dotnet`, the distro feed is the default and Microsoft's feed is the fallback.** The fallback is added only where the distro carries no SDK at all, because mixing the two feeds is what breaks a host. Microsoft's feed carries amd64 only, so any other architecture without a distro SDK is a named skip. The default set is the newest SDK line the feed carries. `--optional` adds every other line, for a host that builds against more than one. - -## Release Upgrades - -`upgrade-host.sh` splits the routine from the rare. `--packages` upgrades within the current release, and `--release` is its own action because the release upgrade is where hosts differ. - -**A release upgrade is refused where this script cannot carry it safely.** Proxmox major upgrades are a documented procedure with their own preconditions, currently the [Proxmox upgrade guide][proxmox-upgrade], and the refusal points there. A distribution that is neither Debian nor Ubuntu is refused too, since the sources rewrite below has no meaning there. Refusing is the point of running this rather than apt by hand. - -**One release at a time.** Both distributions support exactly that, so a host two releases behind is upgraded by running this twice. - -**Debian is carried by rewriting the codename in its apt sources, and only in sources that point at Debian's own mirrors.** A third party repository may have no suite for the new release yet. It is therefore named and left alone, and what to do about it is the operator's call. The sources are backed up to `/var/backups/upgrade-host` first. A backup that cannot be taken stops the upgrade, since it is the only way back. A host on a mirror outside `debian.org`, or one tracking `stable` rather than a codename, is refused. The refusal says to move such a host by editing its sources itself. - -**Ubuntu is carried by `do-release-upgrade`**, which handles its own sources. Whether an LTS or every release is offered is the host's own policy in `/etc/update-manager/release-upgrades`, deliberately not decided here. - -**Preconditions run before the point of no return.** Held packages, half-configured packages from a dpkg audit, and low free space on `/var` are each surfaced first. A release upgrade failing partway is the worst place to find any of them. - -## Restarts, Kernels, and WSL - -A WSL distribution runs the kernel Windows gives it. A restart there is `wsl --shutdown` from Windows followed by a relaunch, and the script says exactly that instead of suggesting `reboot`. Debian does not always write `/var/run/reboot-required`, so the newest kernel in `/boot` is also compared against `uname -r`. A host with no `/boot`, which a container and a WSL distribution both are, has no kernel of its own to compare. - -## GitHub Setup - -Two steps cannot be automated, because they happen in a browser. The public key is registered once as an authentication key and again as a signing key. `setup-github.sh --configure` stops at each, prints the key, and says where to paste it. It then checks that the registration took, by reading the key lists GitHub publishes for the account, which needs no token. A check that could not reach GitHub is reported apart from a key that is not registered. Sending someone to register a key that is already there is the wrong remedy. - -**`--status` is read-only end to end.** Its SSH probes run in batch mode, so a passphrase prompt cannot hang an unattended run. No probe ever enrolls github.com's host key behind the reader's back. Enrolling is `--configure`'s job, and the first enrollment is the one moment a substituted host key would be accepted for good. The offered key is therefore checked against the fingerprints GitHub publishes at `api.github.com/meta` before it is recorded. A check that cannot run is a refusal. - -**The identity comes from the flags, then from what the host already carries, then from the maintainer's default, in that order.** Reading the host first keeps a machine configured for somebody else from being quietly rewritten by a repeatable run. - -**The managed key is probed on its own**, with the host's ssh config and agent excluded. A default identity file or an agent key can authenticate as a different account. A host that reaches GitHub with some other key is working but not managed. The run says so rather than ending in "Done" with the managed key registered nowhere. - -**Signing is proved end to end**, by signing and verifying a commit in a throwaway repository. Reading the settings back cannot catch a wrong `allowed_signers` entry: that reads as correct and fails only when a signature is checked. - -**The key path settings are written in tilde form** (`~/.ssh/id_ed25519.pub`), because git expands the tilde. The hosts configured by hand already hold that form, so writing it leaves an already configured host untouched. - -**`--shared-checkout` exists because `safe.directory` and `core.sharedRepository` are relaxations, not defaults.** They are applied only for a path the caller names, and a host one account uses needs neither. `*` is accepted but called out as turning the ownership check off everywhere. - -## install-skills.sh Is the Exception - -The sibling scripts are independently fetchable, and this one deliberately is not. It drives `scripts/skills_install.py` at the tree root, and the skills content lives in the tree, so a copy fetched alone has nothing to install. Python 3.7 or later is its one dependency, which is why the bootstrap runs it last. Run on a host without one, it stops and names the tools step as its prerequisite. - -## Why There Is No Linter Category - -Neither this tooling nor its Windows sibling installs `markdownlint`, `cspell`, `actionlint`, `editorconfig-checker`, `shellcheck`, `PSScriptAnalyzer` or `ruff`. That is a decision rather than a gap. Each runs as a pinned container image or through `uvx`, which is what keeps a local run and CI the same check. Installing native copies would put a second, unpinned version of each on the host. The only host requirements any of it creates are `docker` and `uv`, and both are already managed here. The [Windows README][windows-readme] states the same decision from its side. - -## bootstrap.sh - -[`bootstrap.sh`][bootstrap] sits beside [`bootstrap.ps1`][bootstrap-ps1] at the top of [`host-setup/`][host-setup-readme] rather than here. Standing up a host with no git and no checkout is one concern across two platforms, not a fifth member of this directory. It needs only `curl` and `tar`, fetches this repository, and runs the scripts here from that tree. - -## Differences From the Windows Tooling - -The comparison is tabulated once, in the [Windows README][windows-readme], so the two columns cannot drift apart. The short version: this side needs three kinds of source where `winget` needs one, and carries the release upgrade Windows Update owns over there. It elevates per command through `sudo` where `winget` raises UAC per installer. It also manages `git-restore-mtime`, which the spec declares not applicable on Windows. - -## Verification - -Read-only first, and nothing below changes the host. - -```shell -host-setup/linux/install-tools.sh --help -host-setup/linux/install-tools.sh --list -host-setup/linux/install-tools.sh # report -host-setup/linux/upgrade-host.sh --status -host-setup/linux/setup-github.sh --status -host-setup/linux/install-skills.sh --report -``` - -Then the dry runs, which print what each action would run: - -```shell -host-setup/linux/install-tools.sh --upgrade --dry-run -host-setup/linux/upgrade-host.sh --release --dry-run -host-setup/linux/setup-github.sh --configure --dry-run -``` - -Two of those are guards rather than previews. `--release --dry-run` on a Proxmox host prints the refusal, not the commands. A docker `--upgrade --dry-run` inside a WSL distribution prints the skip. A `[dry run]` line from either means the guard sits in the wrong place. - -The scripts are checked by `shellcheck`, which runs in CI over every `.sh` file `git ls-files` returns. A local run uses the same `koalaman/shellcheck:stable` container. [`scripts/tests/test_bootstrap.py`][test-bootstrap] asserts that every tool the spec requires on Linux is one `install-tools.sh` can provide, or a recorded exception. It also asserts each script here is tracked executable, so a fresh checkout can run it. - -<!-- Repo --> - -[bootstrap]: ../bootstrap.sh -[bootstrap-ps1]: ../bootstrap.ps1 -[host-setup]: ../../docs/host-setup.md -[host-setup-readme]: ../README.md -[install-skills]: ./install-skills.sh -[install-tools]: ./install-tools.sh -[setup-github]: ./setup-github.sh -[skills-install]: ../../scripts/skills_install.py -[test-bootstrap]: ../../scripts/tests/test_bootstrap.py -[upgrade-host]: ./upgrade-host.sh -[windows-readme]: ../windows/README.md - -<!-- External --> - -[proxmox-upgrade]: https://pve.proxmox.com/wiki/Upgrade_from_8_to_9 +# Linux Host Setup + +The tooling that makes a Debian or Ubuntu based host satisfy the contract in [`docs/host-setup.md`][host-setup], Proxmox and WSL included. That document is the contract, meaning which tools a host must provide and why each floor exists. This directory is how a Linux host comes to satisfy it. + +## What Is Here + +- [`install-tools.sh`][install-tools] installs and upgrades the host tools. It reports what each is installed at, what upstream carries, where it comes from, and its status. +- [`upgrade-host.sh`][upgrade-host] upgrades the packages of the current release. Moving to the next release is a separate action behind its own flag. +- [`setup-github.sh`][setup-github] configures the SSH key, git, and commit signing. It checks both key registrations against what GitHub publishes. +- [`install-skills.sh`][install-skills] drives the hub's skills installer at [`scripts/skills_install.py`][skills-install] from this tree. + +Each runs on its own, and each takes `--help`. + +```shell +host-setup/linux/install-tools.sh # report +host-setup/linux/install-tools.sh --install +host-setup/linux/upgrade-host.sh --status +host-setup/linux/setup-github.sh --status +host-setup/linux/install-skills.sh --report +``` + +Each script is LF with a shebang, and its executable bit is tracked in git. A fresh checkout therefore runs each without a `bash` prefix. That is the Linux form of "this will run", and [`scripts/tests/test_bootstrap.py`][test-bootstrap] asserts both. + +## Requirements + +**A Debian or Ubuntu based host, identified from `/etc/os-release`.** A distribution that is neither but declares `ID_LIKE` debian is treated as Debian, with a warning that it is untested. Proxmox reports itself as its Debian base, so it needs no case of its own. `install-tools.sh` and `upgrade-host.sh` refuse anything else. `setup-github.sh` runs anywhere, and stops only when a missing prerequisite needs apt to install it. + +**Root or sudo.** Nothing here elevates wholesale. Each script runs as the caller and puts `sudo` in front of only the commands that change the host. Every read and all file staging stay unprivileged. A run that is not root and finds no `sudo` refuses up front rather than failing partway. + +**A terminal, or `--yes`.** A run with no terminal on standard input and no `--yes` refuses to change the host. A scheduled run therefore cannot hang on a prompt nobody answers. On `upgrade-host.sh`, `--yes` also keeps the installed configuration file on a packaging conflict. An unattended run has nobody to answer dpkg's prompt, and a replaced config is the harder half to notice afterwards. + +`curl` is not a requirement, it is a managed prerequisite. A minimal image carries none, so every upstream read is guarded. An install run puts `ca-certificates`, `curl`, `gnupg`, and `gpgv` in place before the first tool. + +## Why There Are Three Kinds of Source + +The Windows registry has one source because `winget` tracks upstream. Here the distro package trails upstream on `gh`, on `node`, and on `uv`, so a tool comes from whichever source keeps up: + +- **The distro**, for `git` and `python`, where apt's own package is current enough. +- **An upstream apt repository**, for `gh`, `node`, and `docker`, and for `dotnet` as a fallback, where upstream publishes one. +- **A released binary into `/usr/local/bin`**, for `jq`, `uv`, and `git-restore-mtime`, where upstream publishes no repository. + +No version is written into the script. Each upstream is asked what it carries now, so the script does not go stale between releases. Every step is idempotent, so a keyring or sources file is written only when its content differs. A re-run repairs drift rather than assuming a clean host. + +**A keyring is proved, not trusted.** Before a fetched signing key is installed, `gpgv` checks that it signs the repository's own `InRelease` metadata. An upstream that rotates or adds a key breaks a pinned fingerprint list but not this check. A host where the check cannot run stops rather than trusting the download. A released binary is checked against the sha256 list its upstream publishes beside it, for the same reason. + +**Keyrings land in `/etc/apt/keyrings`, and sources land as deb822 files in `/etc/apt/sources.list.d`.** A predecessor in the old location or the old one-line format is removed first, so apt never reads the same repository twice. + +## PATH and Shadowing + +`/usr/local/bin` precedes `/usr/bin`, which is why the distro's `jq` can stay installed and stay shadowed. The upstream binary wins without removing a package something else may depend on. + +The hazard runs the other way too. A copy of `jq`, `uv`, or `git-restore-mtime` sitting earlier on `PATH` keeps answering after this script installs a newer one. That reads as an upgrade that did not take, so the report names such a shadow. `--upgrade` removes it after a prompt. `--install` removes it only when no managed copy exists yet, since removing a newer shadow would downgrade what `PATH` resolves to. A file a distro package owns is never removed, because deleting it would desync dpkg's database from the filesystem. The remedy named there is the `PATH` order itself. The removal loops, since `PATH` can stack more than one shadow ahead of `/usr/local/bin`. A relative `PATH` entry is never trusted as a shadow at all. + +## What the Report Says + +A report changes nothing and reads the apt cache as it stands. An available version is therefore as current as the last `apt update`. Versions compare like with like: apt versions for an apt managed tool, upstream versions for a standalone binary. `docker` is read from the CLI rather than from the `docker-ce` package. On a WSL distribution, Docker Desktop's integration is a working `docker` with no apt package behind it. Its target is stripped of the epoch and packaging revision for the same like-with-like reason. + +`unmanaged` means the tool is installed from the distro while its upstream repository is unconfigured. The one thing the report must not say is that such a tool is current against the distro's own version. The Windows report uses the same word for a different mechanism, a tool on `PATH` that `winget` knows no package for. + +An install or upgrade collects a tool whose install fails and carries on, so one failure does not strand the rest of the run. A refusal is different and ends the run. An unverifiable keyring, a checksum mismatch, or a declined prompt stops everything, because continuing past one would install something nobody vouched for. + +## Docker, node, and dotnet + +**Inside a WSL distribution, docker comes only from Docker Desktop's own WSL integration, never from installing `docker-ce`.** A native install would run a second engine beside Desktop's. `--install` and `--upgrade` therefore always skip it there and point at Docker Desktop's settings instead. The skip counts as success only where `docker` already answers, so a run cannot exit clean having found nothing working. On a native host, the conflicting packages Docker's own uninstall list names are removed first. Non-root use (`usermod -aG docker`) is left to the operator, as a group choice rather than a question of presence. + +**Installing `node` displaces distro packages.** The upstream package carries `npm` itself and conflicts with the distro's `npm` and `nodejs-doc`. The script asks apt what it would remove and puts that list in front of the operator first. Asking apt beats naming the conflicts here, because the conflict set belongs to the upstream package and changes without notice. The major line installed is whatever upstream currently marks LTS, read from its release index at run time. + +**For `dotnet`, the distro feed is the default and Microsoft's feed is the fallback.** The fallback is added only where the distro carries no SDK at all, because mixing the two feeds is what breaks a host. Microsoft's feed carries amd64 only, so any other architecture without a distro SDK is a named skip. The default set is the newest SDK line the feed carries. `--optional` adds every other line, for a host that builds against more than one. + +## Release Upgrades + +`upgrade-host.sh` splits the routine from the rare. `--packages` upgrades within the current release, and `--release` is its own action because the release upgrade is where hosts differ. + +**A release upgrade is refused where this script cannot carry it safely.** Proxmox major upgrades are a documented procedure with their own preconditions, currently the [Proxmox upgrade guide][proxmox-upgrade], and the refusal points there. A distribution that is neither Debian nor Ubuntu is refused too, since the sources rewrite below has no meaning there. Refusing is the point of running this rather than apt by hand. + +**One release at a time.** Both distributions support exactly that, so a host two releases behind is upgraded by running this twice. + +**Debian is carried by rewriting the codename in its apt sources, and only in sources that point at Debian's own mirrors.** A third party repository may have no suite for the new release yet. It is therefore named and left alone, and what to do about it is the operator's call. The sources are backed up to `/var/backups/upgrade-host` first. A backup that cannot be taken stops the upgrade, since it is the only way back. A host on a mirror outside `debian.org`, or one tracking `stable` rather than a codename, is refused. The refusal says to move such a host by editing its sources itself. + +**Ubuntu is carried by `do-release-upgrade`**, which handles its own sources. Whether an LTS or every release is offered is the host's own policy in `/etc/update-manager/release-upgrades`, deliberately not decided here. + +**Preconditions run before the point of no return.** Held packages, half-configured packages from a dpkg audit, and low free space on `/var` are each surfaced first. A release upgrade failing partway is the worst place to find any of them. + +## Restarts, Kernels, and WSL + +A WSL distribution runs the kernel Windows gives it. A restart there is `wsl --shutdown` from Windows followed by a relaunch, and the script says exactly that instead of suggesting `reboot`. Debian does not always write `/var/run/reboot-required`, so the newest kernel in `/boot` is also compared against `uname -r`. A host with no `/boot`, which a container and a WSL distribution both are, has no kernel of its own to compare. + +## GitHub Setup + +Two steps cannot be automated, because they happen in a browser. The public key is registered once as an authentication key and again as a signing key. `setup-github.sh --configure` stops at each, prints the key, and says where to paste it. It then checks that the registration took, by reading the key lists GitHub publishes for the account, which needs no token. A check that could not reach GitHub is reported apart from a key that is not registered. Sending someone to register a key that is already there is the wrong remedy. + +**`--status` is read-only end to end.** Its SSH probes run in batch mode, so a passphrase prompt cannot hang an unattended run. No probe ever enrolls github.com's host key behind the reader's back. Enrolling is `--configure`'s job, and the first enrollment is the one moment a substituted host key would be accepted for good. The offered key is therefore checked against the fingerprints GitHub publishes at `api.github.com/meta` before it is recorded. A check that cannot run is a refusal. + +**The identity comes from the flags, then from what the host already carries, then from the maintainer's default, in that order.** Reading the host first keeps a machine configured for somebody else from being quietly rewritten by a repeatable run. + +**The managed key is probed on its own**, with the host's ssh config and agent excluded. A default identity file or an agent key can authenticate as a different account. A host that reaches GitHub with some other key is working but not managed. The run says so rather than ending in "Done" with the managed key registered nowhere. + +**Signing is proved end to end**, by signing and verifying a commit in a throwaway repository. Reading the settings back cannot catch a wrong `allowed_signers` entry: that reads as correct and fails only when a signature is checked. + +**The key path settings are written in tilde form** (`~/.ssh/id_ed25519.pub`), because git expands the tilde. The hosts configured by hand already hold that form, so writing it leaves an already configured host untouched. + +**`--shared-checkout` exists because `safe.directory` and `core.sharedRepository` are relaxations, not defaults.** They are applied only for a path the caller names, and a host one account uses needs neither. `*` is accepted but called out as turning the ownership check off everywhere. + +## install-skills.sh Is the Exception + +The sibling scripts are independently fetchable, and this one deliberately is not. It drives `scripts/skills_install.py` at the tree root, and the skills content lives in the tree, so a copy fetched alone has nothing to install. Python 3.7 or later is its one dependency, which is why the bootstrap runs it last. Run on a host without one, it stops and names the tools step as its prerequisite. + +## Why There Is No Linter Category + +Neither this tooling nor its Windows sibling installs `markdownlint`, `cspell`, `actionlint`, `editorconfig-checker`, `shellcheck`, `PSScriptAnalyzer` or `ruff`. That is a decision rather than a gap. Each runs as a pinned container image or through `uvx`, which is what keeps a local run and CI the same check. Installing native copies would put a second, unpinned version of each on the host. The only host requirements any of it creates are `docker` and `uv`, and both are already managed here. The [Windows README][windows-readme] states the same decision from its side. + +## bootstrap.sh + +[`bootstrap.sh`][bootstrap] sits beside [`bootstrap.ps1`][bootstrap-ps1] at the top of [`host-setup/`][host-setup-readme] rather than here. Standing up a host with no git and no checkout is one concern across two platforms, not a fifth member of this directory. It needs only `curl` and `tar`, fetches this repository, and runs the scripts here from that tree. + +## Differences From the Windows Tooling + +The comparison is tabulated once, in the [Windows README][windows-readme], so the two columns cannot drift apart. The short version: this side needs three kinds of source where `winget` needs one, and carries the release upgrade Windows Update owns over there. It elevates per command through `sudo` where `winget` raises UAC per installer. It also manages `git-restore-mtime`, which the spec declares not applicable on Windows. + +## Verification + +Read-only first, and nothing below changes the host. + +```shell +host-setup/linux/install-tools.sh --help +host-setup/linux/install-tools.sh --list +host-setup/linux/install-tools.sh # report +host-setup/linux/upgrade-host.sh --status +host-setup/linux/setup-github.sh --status +host-setup/linux/install-skills.sh --report +``` + +Then the dry runs, which print what each action would run: + +```shell +host-setup/linux/install-tools.sh --upgrade --dry-run +host-setup/linux/upgrade-host.sh --release --dry-run +host-setup/linux/setup-github.sh --configure --dry-run +``` + +Two of those are guards rather than previews. `--release --dry-run` on a Proxmox host prints the refusal, not the commands. A docker `--upgrade --dry-run` inside a WSL distribution prints the skip. A `[dry run]` line from either means the guard sits in the wrong place. + +The scripts are checked by `shellcheck`, which runs in CI over every `.sh` file `git ls-files` returns. A local run uses the same `koalaman/shellcheck:stable` container. [`scripts/tests/test_bootstrap.py`][test-bootstrap] asserts that every tool the spec requires on Linux is one `install-tools.sh` can provide, or a recorded exception. It also asserts each script here is tracked executable, so a fresh checkout can run it. + +<!-- Repo --> + +[bootstrap]: ../bootstrap.sh +[bootstrap-ps1]: ../bootstrap.ps1 +[host-setup]: ../../docs/host-setup.md +[host-setup-readme]: ../README.md +[install-skills]: ./install-skills.sh +[install-tools]: ./install-tools.sh +[setup-github]: ./setup-github.sh +[skills-install]: ../../scripts/skills_install.py +[test-bootstrap]: ../../scripts/tests/test_bootstrap.py +[upgrade-host]: ./upgrade-host.sh +[windows-readme]: ../windows/README.md + +<!-- External --> + +[proxmox-upgrade]: https://pve.proxmox.com/wiki/Upgrade_from_8_to_9 diff --git a/host-setup/windows/README.md b/host-setup/windows/README.md index 0dd9de1b..4016ad80 100644 --- a/host-setup/windows/README.md +++ b/host-setup/windows/README.md @@ -1,147 +1,147 @@ -# Windows Host Setup - -The tooling that makes a native Windows host satisfy the contract in [`docs/host-setup.md`][host-setup], through `winget` and PowerShell 7. That document is the contract, meaning which tools a host must provide and why each floor exists. This directory is how a Windows host comes to satisfy it. - -## What Is Here - -- [`install-tools.ps1`][install-tools] installs and upgrades the host tools, and reports what each one is installed at, where it came from, and which scope it sits in. Installing, upgrading or reinstalling `docker` also brings the WSL platform up to Docker Desktop's own floor where it is behind. -- [`upgrade-host.ps1`][upgrade-host] upgrades the packages `winget` manages and updates the WSL platform. -- [`setup-github.ps1`][setup-github] configures the SSH key, git, and commit signing. -- [`setup-wsl.ps1`][setup-wsl] installs a WSL distribution and reports how Docker Desktop is integrated with the ones this host runs. - -Each runs on its own, and each takes `-Help`. - -```powershell -host-setup\windows\install-tools.ps1 # report -host-setup\windows\install-tools.ps1 -Install -host-setup\windows\upgrade-host.ps1 -Status -host-setup\windows\setup-github.ps1 -Status -host-setup\windows\setup-wsl.ps1 -Status -``` - -## Requirements - -**PowerShell 7 or later**, which is `pwsh` rather than the `powershell.exe` that ships with Windows. Each script refuses an older one and prints `winget install --id Microsoft.PowerShell --exact --source winget` as the remedy. `pwsh` is deliberately not a managed tool: a host that cannot run these scripts cannot be repaired by them. - -**winget**, which arrives with App Installer from the Microsoft Store. - -**Script execution.** A `git clone` carries no mark of the web, so these run under the default `RemoteSigned` policy. A browser-downloaded zip does carry one, and is blocked until `Unblock-File` clears the mark. The `.\` prefix is required when running a script from the current directory, exactly as it is for [`agent-safety/install.ps1`][agent-safety]. - -`pwsh -File .\install-tools.ps1` answers the `.\` rule and **not** the policy, which still applies to it: on a marked file under `RemoteSigned` it fails with a `SecurityError` naming the file as unsigned. The form that runs whatever the policy says is `pwsh -ExecutionPolicy Bypass -File .\install-tools.ps1`, which is what [`docs/host-setup.md`][host-setup] already gives for the write-safety installer. Prefer clearing the mark with `Unblock-File` over bypassing, since the bypass covers every script that run touches. - -## Why winget Is the Only Source - -Every tool the contract names has a winget package, so nothing here carries a fallback. That is the whole difference from the Linux script, which needs three kinds of source because the distribution's package trails upstream on `gh`, on `node` and on `uv`. Where `winget` tracks upstream, the machinery that exists to work around a stale feed has nothing to do. - -A tool that turns out to have no winget package is a finding to raise rather than a second source to add quietly, because the moment one tool comes from somewhere else this directory stops being one program and becomes two. - -## Elevation and Scope - -**Run these unelevated.** No `--scope` is passed unless `-Scope` names one, so `winget` acts on the copy it finds and an installer that needs administrator raises its own prompt. That is the path with the fewest failures, for three reasons that point the same way: forcing user scope installs a second copy beside a working machine wide one rather than upgrading it, some installers fail outright when launched from an already elevated process, and a user scope install made from an elevated process lands in the administrator's profile rather than the caller's. - -Nothing here elevates itself. A run that is already elevated says so and carries on, since that is a caution rather than a refusal. - -**Scope is measured, not assumed.** `winget list --scope user` and `--scope machine` answer separately, so the report names where each tool actually sits and catches the case worth catching, which is a tool installed in **both** scopes with one copy shadowing the other on `PATH`. - -**`-Reinstall` is the only action that removes anything**, and it always asks first. An `-Upgrade` whose `-Scope` disagrees with the installed copy refuses and names it, rather than upgrading in place or adding a second copy. - -**A state that could not be read is never reported as an absence.** Where `winget` does not answer what is installed, the tool reports `unreadable` rather than `missing`, and an install or upgrade skips it and collects it as a failure. Installing against a state nobody measured is how a second copy lands beside a first one that was there all along. - -**What provenance can and cannot be detected.** Scope is solid, and so is a tool that answers on `PATH` while `winget` knows no package for it, which reports as `unmanaged`. Whether a package was installed *by* winget is not solid and is not claimed: winget runs the vendor's own installer for an `exe` or an `msi`, so the resulting uninstall entry is identical whether winget invoked it or a person did. The one positive marker is the uninstall key winget writes for itself on a portable or archive package, which the report names where it is present and says nothing about where it is absent. - -## Self-Updating Packages - -Some applications update themselves and never rewrite the version recorded at install time. `winget` reports them as permanently behind, and its manifest marks them as requiring explicit targeting so an upgrade of everything leaves them alone. - -These are listed apart, left alone, and printed with **no command beside them**. Offering one invites a full reinstall over a working, already current copy in pursuit of a number that will not move. `MSYS2` is the worked example: it upgrades through `pacman` from inside the msys shell, and the version `winget` shows is the installer's rather than the one it runs. `install-tools.ps1` reports such a tool as `self-updating` rather than `outdated` for the same reason. - -## Why There Is No Linter Category - -Neither this tooling nor its Linux sibling installs `markdownlint`, `cspell`, `actionlint`, `editorconfig-checker`, `shellcheck`, `PSScriptAnalyzer` or `ruff`, and that is a decision rather than a gap. - -Each of those runs as a pinned container image or through `uvx`, which is what keeps a local run and CI the same check: the image tag fixes the version. Installing native copies through `winget` would put a second, unpinned version of each on the host, and a local run would then differ from CI, which is the exact property the pinned images exist to guarantee. The only host requirements any of it creates are `docker` and `uv`, and both are already in the registry. - -## bootstrap.ps1 - -[`bootstrap.ps1`][bootstrap-ps1] is a fifth script, and the odd one out: it sits beside [`bootstrap.sh`][bootstrap] at the top of [`host-setup/`][host-setup-readme] rather than here, since it is the same concern as that file rather than a fifth member of this registry. It exists to stand up a host that has no git and no checkout, the same problem `bootstrap.sh` solves on Linux. - -An unverified loader is worse than none, and that is why this one does more than fetch a tarball. It runs under Windows PowerShell 5.1, the one shell a fresh Windows host guarantees, and hands off to `pwsh` only once it has found or installed it through `winget`, since every script in this directory refuses to run under anything older. It pins TLS 1.2 itself rather than assume a fresh console's default reaches GitHub. And it checks a fetched tree for the marker it wrote before removing anything under `-Dir`, the same rule `bootstrap.sh` follows on Linux. None of that verifies the tooling it goes on to run, which stays exactly as unverified against a genuinely fresh host as it always was. It verifies the one step earlier this loader adds, standing up the interpreter everything past it depends on. - -```powershell -pwsh -NoProfile -File ..\bootstrap.ps1 -Help -..\bootstrap.ps1 -Report -DryRun -``` - -## Docker Desktop and WSL - -`setup-wsl.ps1` **reports** the Docker Desktop integration and never writes it. Docker holds those settings in memory and rewrites its settings file from that copy while it runs, so an edit made here is discarded at Docker's next save and an edit made while it is stopped is undone by the next start. Change it in Docker Desktop under Settings, Resources, WSL integration. - -`upgrade-host.ps1 -Wsl` **refuses while Docker Desktop is running**, because Docker holds the WSL service open and the update then fails part way rather than declining. Quit Docker from its tray icon first, since pausing it is not enough. The refusal fires under `-DryRun` too, so a dry run reports the truth rather than printing a command that would not have worked. - -Docker's own `docker-desktop` distribution is excluded from every distribution listing, since it is Docker's rather than one an operator installed. - -`install-tools.ps1` checks, before installing or upgrading `docker`, that `wsl.exe` is present and reports a WSL version at or above `2.1.5`, Docker Desktop's own documented floor for the platform it depends on. Where `wsl.exe` is missing entirely it skips `docker` and names the remedy (`wsl --install --no-distribution`), the one case this still leaves to a person: standing up WSL from nothing is a different action from bringing an existing platform current, the same reasoning that keeps a distribution *install* in `setup-wsl.ps1` rather than folded in here. The same check surfaces as a note under `-Report`, read-only, before a caller ever runs `-Install`. - -Where WSL is present but behind the floor, or `docker` itself is about to change version, `install-tools.ps1` drives the fix itself rather than only naming it, asking first unless `-Yes` was given. Where Docker Desktop is running, it stops it through its own CLI (`docker desktop stop`, not the tray icon) rather than leave it up through the change, runs `wsl --update` where the floor is not met, and once the `docker` package itself is also settled, shuts every WSL distribution down with `wsl --shutdown` and starts Docker Desktop again (`docker desktop start`). A run that finds Docker Desktop already stopped has nothing to restore, so it stops there too: neither the shutdown nor the restart runs. - -This moved a boundary the tool used to hold: WSL used to be read-only here, and `upgrade-host.ps1 -Wsl` was the only thing that ever ran `wsl --update`, on the reasoning that an update restarts every distribution and that does not belong as a side effect of installing a different tool. Two things moved it. Docker Desktop's own per-distro WSL integration goes stale across an engine bump often enough to have a name on Docker's own tracker (`WSL integration with distro '<name>' unexpectedly stopped`), and the dialog's own "Restart the WSL integration" button does not clear it, since it retries the proxy inside the distro that is already running against the same stale state. A full stop, `wsl --shutdown`, start cycle does clear it. And once a `docker` version bump already needed that exact stop-then-restart window for its own integration to recover, updating WSL inside that same window costs nothing extra, and skips the `upgrade-host.ps1 -Wsl` refusal a Docker Desktop already running would otherwise walk straight into. - -`wsl --update` installs through its own MSI and raises a UAC prompt when this `pwsh` is not itself elevated, the state the rest of this tooling deliberately stays in. Nothing can answer that prompt unattended, so where the process is neither elevated nor at an interactive console, `install-tools.ps1` refuses to start the update rather than hang, and restores Docker Desktop to how it found it first. - -## Differences From the Linux Tooling - -| Linux | Windows | Why | -| --- | --- | --- | -| `upgrade-host.sh --release` moves to the next distribution release | no peer | Windows Update owns a feature update, and an action pretending to drive one is the one thing this must not carry | -| `install-tools.sh` carries four functions per tool | `install-tools.ps1` carries one registry record per tool | Every source is `winget`, so the per-tool variation those functions exist for does not arise | -| Actions, the last one given wins | Actions, name one | A `param()` block records which switches were given and not their order, and refusing beats silently discarding an intent | -| `git-restore-mtime` is managed | not managed | The spec declares it not applicable on Windows, since it serves a Linux deploy path | -| `install-tools.sh` refuses docker entirely inside a WSL *distribution* | `install-tools.ps1` checks the WSL *platform* version before installing docker | A WSL distribution takes docker only from Docker Desktop's own WSL integration, and Windows needs WSL2 present for Docker Desktop's own backend | -| `sudo` re-runs a command as root | nothing elevates | `winget` raises UAC per installer, which is the path with the fewest failures | -| `unmanaged` means the upstream repository is unconfigured | `unmanaged` means the tool is on `PATH` and winget knows no package for it | The same question, by a different mechanism | -| `credential.helper cache --timeout=3600` | `credential.helper manager`, and only where unset | Git Credential Manager ships with Git for Windows | -| `ssh-agent` is a socket, started per shell | `ssh-agent` is a Windows service, reported and not started | Starting it needs administrator, and nothing here elevates | -| no WSL script | `setup-wsl.ps1` | WSL is a Windows-side concern with no Linux-side peer | - -The scripts here share no file with each other, and the roughly thirty duplicated lines of logging, the dry-run wrapper and the confirmation prompt are identical rather than merely similar. That is the same rule the Linux scripts follow, for the same reason, and it is stated in [`host-setup/README.md`][host-setup-readme]. Do not factor them out. - -## Verification - -Read-only first, and nothing below changes the host. - -```powershell -pwsh -NoProfile -File host-setup\windows\install-tools.ps1 -Help -host-setup\windows\install-tools.ps1 -List -host-setup\windows\install-tools.ps1 -Report -host-setup\windows\upgrade-host.ps1 -Status -host-setup\windows\setup-github.ps1 -Status -host-setup\windows\setup-wsl.ps1 -Status -host-setup\bootstrap.ps1 -Report -DryRun -``` - -Then the dry runs, which print what each action would do: - -```powershell -host-setup\windows\install-tools.ps1 -Upgrade -DryRun -host-setup\windows\upgrade-host.ps1 -Packages -DryRun -host-setup\windows\setup-github.ps1 -Configure -DryRun -host-setup\windows\setup-wsl.ps1 -Install Debian -DryRun -``` - -Two of those are guards rather than previews, and each prints a refusal rather than a command: `upgrade-host.ps1 -Wsl -DryRun` on a host running Docker Desktop, and an `-Upgrade` whose `-Scope` disagrees with the installed copy. A `[dry run]` line from either means the guard sits in the wrong place. - -The scripts are checked by `PSScriptAnalyzer`, which runs in CI as the peer of the `shellcheck` step and locally through the invocation in [`GOVERNANCE.md`][governance]. [`scripts/tests/test_bootstrap.py`][test-bootstrap] asserts that every tool the spec requires is one this registry carries, and that no script here opens with a shebang. - -<!-- Repo --> - -[agent-safety]: ../agent-safety/install.ps1 -[bootstrap]: ../bootstrap.sh -[bootstrap-ps1]: ../bootstrap.ps1 -[governance]: ../../GOVERNANCE.md -[host-setup]: ../../docs/host-setup.md -[host-setup-readme]: ../README.md -[install-tools]: ./install-tools.ps1 -[setup-github]: ./setup-github.ps1 -[setup-wsl]: ./setup-wsl.ps1 -[test-bootstrap]: ../../scripts/tests/test_bootstrap.py -[upgrade-host]: ./upgrade-host.ps1 +# Windows Host Setup + +The tooling that makes a native Windows host satisfy the contract in [`docs/host-setup.md`][host-setup], through `winget` and PowerShell 7. That document is the contract, meaning which tools a host must provide and why each floor exists. This directory is how a Windows host comes to satisfy it. + +## What Is Here + +- [`install-tools.ps1`][install-tools] installs and upgrades the host tools, and reports what each one is installed at, where it came from, and which scope it sits in. Installing, upgrading or reinstalling `docker` also brings the WSL platform up to Docker Desktop's own floor where it is behind. +- [`upgrade-host.ps1`][upgrade-host] upgrades the packages `winget` manages and updates the WSL platform. +- [`setup-github.ps1`][setup-github] configures the SSH key, git, and commit signing. +- [`setup-wsl.ps1`][setup-wsl] installs a WSL distribution and reports how Docker Desktop is integrated with the ones this host runs. + +Each runs on its own, and each takes `-Help`. + +```powershell +host-setup\windows\install-tools.ps1 # report +host-setup\windows\install-tools.ps1 -Install +host-setup\windows\upgrade-host.ps1 -Status +host-setup\windows\setup-github.ps1 -Status +host-setup\windows\setup-wsl.ps1 -Status +``` + +## Requirements + +**PowerShell 7 or later**, which is `pwsh` rather than the `powershell.exe` that ships with Windows. Each script refuses an older one and prints `winget install --id Microsoft.PowerShell --exact --source winget` as the remedy. `pwsh` is deliberately not a managed tool: a host that cannot run these scripts cannot be repaired by them. + +**winget**, which arrives with App Installer from the Microsoft Store. + +**Script execution.** A `git clone` carries no mark of the web, so these run under the default `RemoteSigned` policy. A browser-downloaded zip does carry one, and is blocked until `Unblock-File` clears the mark. The `.\` prefix is required when running a script from the current directory, exactly as it is for [`agent-safety/install.ps1`][agent-safety]. + +`pwsh -File .\install-tools.ps1` answers the `.\` rule and **not** the policy, which still applies to it: on a marked file under `RemoteSigned` it fails with a `SecurityError` naming the file as unsigned. The form that runs whatever the policy says is `pwsh -ExecutionPolicy Bypass -File .\install-tools.ps1`, which is what [`docs/host-setup.md`][host-setup] already gives for the write-safety installer. Prefer clearing the mark with `Unblock-File` over bypassing, since the bypass covers every script that run touches. + +## Why winget Is the Only Source + +Every tool the contract names has a winget package, so nothing here carries a fallback. That is the whole difference from the Linux script, which needs three kinds of source because the distribution's package trails upstream on `gh`, on `node` and on `uv`. Where `winget` tracks upstream, the machinery that exists to work around a stale feed has nothing to do. + +A tool that turns out to have no winget package is a finding to raise rather than a second source to add quietly, because the moment one tool comes from somewhere else this directory stops being one program and becomes two. + +## Elevation and Scope + +**Run these unelevated.** No `--scope` is passed unless `-Scope` names one, so `winget` acts on the copy it finds and an installer that needs administrator raises its own prompt. That is the path with the fewest failures, for three reasons that point the same way: forcing user scope installs a second copy beside a working machine wide one rather than upgrading it, some installers fail outright when launched from an already elevated process, and a user scope install made from an elevated process lands in the administrator's profile rather than the caller's. + +Nothing here elevates itself. A run that is already elevated says so and carries on, since that is a caution rather than a refusal. + +**Scope is measured, not assumed.** `winget list --scope user` and `--scope machine` answer separately, so the report names where each tool actually sits and catches the case worth catching, which is a tool installed in **both** scopes with one copy shadowing the other on `PATH`. + +**`-Reinstall` is the only action that removes anything**, and it always asks first. An `-Upgrade` whose `-Scope` disagrees with the installed copy refuses and names it, rather than upgrading in place or adding a second copy. + +**A state that could not be read is never reported as an absence.** Where `winget` does not answer what is installed, the tool reports `unreadable` rather than `missing`, and an install or upgrade skips it and collects it as a failure. Installing against a state nobody measured is how a second copy lands beside a first one that was there all along. + +**What provenance can and cannot be detected.** Scope is solid, and so is a tool that answers on `PATH` while `winget` knows no package for it, which reports as `unmanaged`. Whether a package was installed *by* winget is not solid and is not claimed: winget runs the vendor's own installer for an `exe` or an `msi`, so the resulting uninstall entry is identical whether winget invoked it or a person did. The one positive marker is the uninstall key winget writes for itself on a portable or archive package, which the report names where it is present and says nothing about where it is absent. + +## Self-Updating Packages + +Some applications update themselves and never rewrite the version recorded at install time. `winget` reports them as permanently behind, and its manifest marks them as requiring explicit targeting so an upgrade of everything leaves them alone. + +These are listed apart, left alone, and printed with **no command beside them**. Offering one invites a full reinstall over a working, already current copy in pursuit of a number that will not move. `MSYS2` is the worked example: it upgrades through `pacman` from inside the msys shell, and the version `winget` shows is the installer's rather than the one it runs. `install-tools.ps1` reports such a tool as `self-updating` rather than `outdated` for the same reason. + +## Why There Is No Linter Category + +Neither this tooling nor its Linux sibling installs `markdownlint`, `cspell`, `actionlint`, `editorconfig-checker`, `shellcheck`, `PSScriptAnalyzer` or `ruff`, and that is a decision rather than a gap. + +Each of those runs as a pinned container image or through `uvx`, which is what keeps a local run and CI the same check: the image tag fixes the version. Installing native copies through `winget` would put a second, unpinned version of each on the host, and a local run would then differ from CI, which is the exact property the pinned images exist to guarantee. The only host requirements any of it creates are `docker` and `uv`, and both are already in the registry. + +## bootstrap.ps1 + +[`bootstrap.ps1`][bootstrap-ps1] is a fifth script, and the odd one out: it sits beside [`bootstrap.sh`][bootstrap] at the top of [`host-setup/`][host-setup-readme] rather than here, since it is the same concern as that file rather than a fifth member of this registry. It exists to stand up a host that has no git and no checkout, the same problem `bootstrap.sh` solves on Linux. + +An unverified loader is worse than none, and that is why this one does more than fetch a tarball. It runs under Windows PowerShell 5.1, the one shell a fresh Windows host guarantees, and hands off to `pwsh` only once it has found or installed it through `winget`, since every script in this directory refuses to run under anything older. It pins TLS 1.2 itself rather than assume a fresh console's default reaches GitHub. And it checks a fetched tree for the marker it wrote before removing anything under `-Dir`, the same rule `bootstrap.sh` follows on Linux. None of that verifies the tooling it goes on to run, which stays exactly as unverified against a genuinely fresh host as it always was. It verifies the one step earlier this loader adds, standing up the interpreter everything past it depends on. + +```powershell +pwsh -NoProfile -File ..\bootstrap.ps1 -Help +..\bootstrap.ps1 -Report -DryRun +``` + +## Docker Desktop and WSL + +`setup-wsl.ps1` **reports** the Docker Desktop integration and never writes it. Docker holds those settings in memory and rewrites its settings file from that copy while it runs, so an edit made here is discarded at Docker's next save and an edit made while it is stopped is undone by the next start. Change it in Docker Desktop under Settings, Resources, WSL integration. + +`upgrade-host.ps1 -Wsl` **refuses while Docker Desktop is running**, because Docker holds the WSL service open and the update then fails part way rather than declining. Quit Docker from its tray icon first, since pausing it is not enough. The refusal fires under `-DryRun` too, so a dry run reports the truth rather than printing a command that would not have worked. + +Docker's own `docker-desktop` distribution is excluded from every distribution listing, since it is Docker's rather than one an operator installed. + +`install-tools.ps1` checks, before installing or upgrading `docker`, that `wsl.exe` is present and reports a WSL version at or above `2.1.5`, Docker Desktop's own documented floor for the platform it depends on. Where `wsl.exe` is missing entirely it skips `docker` and names the remedy (`wsl --install --no-distribution`), the one case this still leaves to a person: standing up WSL from nothing is a different action from bringing an existing platform current, the same reasoning that keeps a distribution *install* in `setup-wsl.ps1` rather than folded in here. The same check surfaces as a note under `-Report`, read-only, before a caller ever runs `-Install`. + +Where WSL is present but behind the floor, or `docker` itself is about to change version, `install-tools.ps1` drives the fix itself rather than only naming it, asking first unless `-Yes` was given. Where Docker Desktop is running, it stops it through its own CLI (`docker desktop stop`, not the tray icon) rather than leave it up through the change, runs `wsl --update` where the floor is not met, and once the `docker` package itself is also settled, shuts every WSL distribution down with `wsl --shutdown` and starts Docker Desktop again (`docker desktop start`). A run that finds Docker Desktop already stopped has nothing to restore, so it stops there too: neither the shutdown nor the restart runs. + +This moved a boundary the tool used to hold: WSL used to be read-only here, and `upgrade-host.ps1 -Wsl` was the only thing that ever ran `wsl --update`, on the reasoning that an update restarts every distribution and that does not belong as a side effect of installing a different tool. Two things moved it. Docker Desktop's own per-distro WSL integration goes stale across an engine bump often enough to have a name on Docker's own tracker (`WSL integration with distro '<name>' unexpectedly stopped`), and the dialog's own "Restart the WSL integration" button does not clear it, since it retries the proxy inside the distro that is already running against the same stale state. A full stop, `wsl --shutdown`, start cycle does clear it. And once a `docker` version bump already needed that exact stop-then-restart window for its own integration to recover, updating WSL inside that same window costs nothing extra, and skips the `upgrade-host.ps1 -Wsl` refusal a Docker Desktop already running would otherwise walk straight into. + +`wsl --update` installs through its own MSI and raises a UAC prompt when this `pwsh` is not itself elevated, the state the rest of this tooling deliberately stays in. Nothing can answer that prompt unattended, so where the process is neither elevated nor at an interactive console, `install-tools.ps1` refuses to start the update rather than hang, and restores Docker Desktop to how it found it first. + +## Differences From the Linux Tooling + +| Linux | Windows | Why | +| --- | --- | --- | +| `upgrade-host.sh --release` moves to the next distribution release | no peer | Windows Update owns a feature update, and an action pretending to drive one is the one thing this must not carry | +| `install-tools.sh` carries four functions per tool | `install-tools.ps1` carries one registry record per tool | Every source is `winget`, so the per-tool variation those functions exist for does not arise | +| Actions, the last one given wins | Actions, name one | A `param()` block records which switches were given and not their order, and refusing beats silently discarding an intent | +| `git-restore-mtime` is managed | not managed | The spec declares it not applicable on Windows, since it serves a Linux deploy path | +| `install-tools.sh` refuses docker entirely inside a WSL *distribution* | `install-tools.ps1` checks the WSL *platform* version before installing docker | A WSL distribution takes docker only from Docker Desktop's own WSL integration, and Windows needs WSL2 present for Docker Desktop's own backend | +| `sudo` re-runs a command as root | nothing elevates | `winget` raises UAC per installer, which is the path with the fewest failures | +| `unmanaged` means the upstream repository is unconfigured | `unmanaged` means the tool is on `PATH` and winget knows no package for it | The same question, by a different mechanism | +| `credential.helper cache --timeout=3600` | `credential.helper manager`, and only where unset | Git Credential Manager ships with Git for Windows | +| `ssh-agent` is a socket, started per shell | `ssh-agent` is a Windows service, reported and not started | Starting it needs administrator, and nothing here elevates | +| no WSL script | `setup-wsl.ps1` | WSL is a Windows-side concern with no Linux-side peer | + +The scripts here share no file with each other, and the roughly thirty duplicated lines of logging, the dry-run wrapper and the confirmation prompt are identical rather than merely similar. That is the same rule the Linux scripts follow, for the same reason, and it is stated in [`host-setup/README.md`][host-setup-readme]. Do not factor them out. + +## Verification + +Read-only first, and nothing below changes the host. + +```powershell +pwsh -NoProfile -File host-setup\windows\install-tools.ps1 -Help +host-setup\windows\install-tools.ps1 -List +host-setup\windows\install-tools.ps1 -Report +host-setup\windows\upgrade-host.ps1 -Status +host-setup\windows\setup-github.ps1 -Status +host-setup\windows\setup-wsl.ps1 -Status +host-setup\bootstrap.ps1 -Report -DryRun +``` + +Then the dry runs, which print what each action would do: + +```powershell +host-setup\windows\install-tools.ps1 -Upgrade -DryRun +host-setup\windows\upgrade-host.ps1 -Packages -DryRun +host-setup\windows\setup-github.ps1 -Configure -DryRun +host-setup\windows\setup-wsl.ps1 -Install Debian -DryRun +``` + +Two of those are guards rather than previews, and each prints a refusal rather than a command: `upgrade-host.ps1 -Wsl -DryRun` on a host running Docker Desktop, and an `-Upgrade` whose `-Scope` disagrees with the installed copy. A `[dry run]` line from either means the guard sits in the wrong place. + +The scripts are checked by `PSScriptAnalyzer`, which runs in CI as the peer of the `shellcheck` step and locally through the invocation in [`GOVERNANCE.md`][governance]. [`scripts/tests/test_bootstrap.py`][test-bootstrap] asserts that every tool the spec requires is one this registry carries, and that no script here opens with a shebang. + +<!-- Repo --> + +[agent-safety]: ../agent-safety/install.ps1 +[bootstrap]: ../bootstrap.sh +[bootstrap-ps1]: ../bootstrap.ps1 +[governance]: ../../GOVERNANCE.md +[host-setup]: ../../docs/host-setup.md +[host-setup-readme]: ../README.md +[install-tools]: ./install-tools.ps1 +[setup-github]: ./setup-github.ps1 +[setup-wsl]: ./setup-wsl.ps1 +[test-bootstrap]: ../../scripts/tests/test_bootstrap.py +[upgrade-host]: ./upgrade-host.ps1 diff --git a/host-setup/windows/install-skills.ps1 b/host-setup/windows/install-skills.ps1 index 8218bc45..19ff0da8 100644 --- a/host-setup/windows/install-skills.ps1 +++ b/host-setup/windows/install-skills.ps1 @@ -1,92 +1,92 @@ -# Installs the fleet skills for the current user, by driving the hub-hosted installer in the tree this script sits in. -# This is the one script here that reaches outside host-setup/, deliberately: the skills content lives at the tree root, so a copy fetched without the tree has nothing to install, and the independent-fetchability rule the sibling scripts follow cannot apply to it. -# Python is its one dependency, which is why the bootstrap runs it last: install-tools.ps1 provides the interpreter before this needs one. -# -# Every step is idempotent, because the installer it drives is. -# SKILLS_SOURCE_COMMIT, where the caller sets it, names the commit of a tree git cannot answer for, which is what a tarball fetched by the bootstrap is. - -[CmdletBinding()] -param( - [Alias('r')][switch]$Report, - [Alias('n')][switch]$DryRun, - [Alias('y')][switch]$Yes, - [Alias('h')][switch]$Help -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' -# A non-zero exit from a probed interpreter is an answer here rather than a failure. -$PSNativeCommandUseErrorActionPreference = $false - -$WANT_REPORT = [bool]$Report -$DRY_RUN = [bool]$DryRun -# -Yes is accepted for symmetry with the sibling scripts, since the installer never prompts. -$null = $Yes -$WANT_HELP = [bool]$Help - -function info { param([string]$Message) Write-Host " $Message" } -function die { param([string]$Message) [Console]::Error.WriteLine("ERROR: $Message"); exit 1 } - -function usage { - # The closing marker of a here-string has to sit at column 0, so this block is deliberately unindented. - Write-Host @' -Usage: install-skills.ps1 [options] - -Installs the fleet skills for the current user, by running scripts/skills_install.py from this -tree. The installer copies the skills to ~/.agents/skills/ for Codex and opencode, registers the -Claude Code plugin where the claude CLI is present and says so where it is not, and stamps what it -installed so a later report can answer whether this machine is current. - -Actions, name one, default install: - -r, -Report Read-only: is this machine's skills install current against this tree? - -h, -Help Show this help - -Options: - -n, -DryRun Print the command instead of running it - -y, -Yes Accepted for symmetry with the sibling scripts, since the installer never prompts -'@ -} - -# The first candidate that is a Python 3.7+, since the installer needs `from __future__ import annotations`. -# Each available launcher is version-checked before being committed to, because none guarantees 3.7+ by construction. -function Find-Python { - $candidates = @( - @{ Exe = 'py'; Arguments = @('-3') }, - @{ Exe = 'python3'; Arguments = @() }, - @{ Exe = 'python'; Arguments = @() } - ) - foreach ($candidate in $candidates) { - if (-not (Get-Command $candidate.Exe -ErrorAction SilentlyContinue)) { continue } - & $candidate.Exe @($candidate.Arguments) -c 'import sys; sys.exit(0 if sys.version_info >= (3, 7) else 1)' 2>$null - if ($LASTEXITCODE -eq 0) { return $candidate } - } - return $null -} - -function main { - if ($WANT_HELP) { usage; exit 0 } - - $root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) - $installer = Join-Path $root 'scripts' | Join-Path -ChildPath 'skills_install.py' - if (-not (Test-Path $installer)) { - die 'This tree carries no scripts/skills_install.py, so there is nothing to drive' - } - - $python = Find-Python - if (-not $python) { - die 'Python 3.7+ not found. install-tools.ps1 provides it, so run the tools step first and this one after.' - } - - $arguments = @() - if ($WANT_REPORT) { $arguments += '--report' } - - if ($DRY_RUN) { - info "[dry run] $($python.Exe) $($python.Arguments -join ' ') $installer $($arguments -join ' ')" - exit 0 - } - - & $python.Exe @($python.Arguments) $installer @arguments - exit $LASTEXITCODE -} - -main +# Installs the fleet skills for the current user, by driving the hub-hosted installer in the tree this script sits in. +# This is the one script here that reaches outside host-setup/, deliberately: the skills content lives at the tree root, so a copy fetched without the tree has nothing to install, and the independent-fetchability rule the sibling scripts follow cannot apply to it. +# Python is its one dependency, which is why the bootstrap runs it last: install-tools.ps1 provides the interpreter before this needs one. +# +# Every step is idempotent, because the installer it drives is. +# SKILLS_SOURCE_COMMIT, where the caller sets it, names the commit of a tree git cannot answer for, which is what a tarball fetched by the bootstrap is. + +[CmdletBinding()] +param( + [Alias('r')][switch]$Report, + [Alias('n')][switch]$DryRun, + [Alias('y')][switch]$Yes, + [Alias('h')][switch]$Help +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +# A non-zero exit from a probed interpreter is an answer here rather than a failure. +$PSNativeCommandUseErrorActionPreference = $false + +$WANT_REPORT = [bool]$Report +$DRY_RUN = [bool]$DryRun +# -Yes is accepted for symmetry with the sibling scripts, since the installer never prompts. +$null = $Yes +$WANT_HELP = [bool]$Help + +function info { param([string]$Message) Write-Host " $Message" } +function die { param([string]$Message) [Console]::Error.WriteLine("ERROR: $Message"); exit 1 } + +function usage { + # The closing marker of a here-string has to sit at column 0, so this block is deliberately unindented. + Write-Host @' +Usage: install-skills.ps1 [options] + +Installs the fleet skills for the current user, by running scripts/skills_install.py from this +tree. The installer copies the skills to ~/.agents/skills/ for Codex and opencode, registers the +Claude Code plugin where the claude CLI is present and says so where it is not, and stamps what it +installed so a later report can answer whether this machine is current. + +Actions, name one, default install: + -r, -Report Read-only: is this machine's skills install current against this tree? + -h, -Help Show this help + +Options: + -n, -DryRun Print the command instead of running it + -y, -Yes Accepted for symmetry with the sibling scripts, since the installer never prompts +'@ +} + +# The first candidate that is a Python 3.7+, since the installer needs `from __future__ import annotations`. +# Each available launcher is version-checked before being committed to, because none guarantees 3.7+ by construction. +function Find-Python { + $candidates = @( + @{ Exe = 'py'; Arguments = @('-3') }, + @{ Exe = 'python3'; Arguments = @() }, + @{ Exe = 'python'; Arguments = @() } + ) + foreach ($candidate in $candidates) { + if (-not (Get-Command $candidate.Exe -ErrorAction SilentlyContinue)) { continue } + & $candidate.Exe @($candidate.Arguments) -c 'import sys; sys.exit(0 if sys.version_info >= (3, 7) else 1)' 2>$null + if ($LASTEXITCODE -eq 0) { return $candidate } + } + return $null +} + +function main { + if ($WANT_HELP) { usage; exit 0 } + + $root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) + $installer = Join-Path $root 'scripts' | Join-Path -ChildPath 'skills_install.py' + if (-not (Test-Path $installer)) { + die 'This tree carries no scripts/skills_install.py, so there is nothing to drive' + } + + $python = Find-Python + if (-not $python) { + die 'Python 3.7+ not found. install-tools.ps1 provides it, so run the tools step first and this one after.' + } + + $arguments = @() + if ($WANT_REPORT) { $arguments += '--report' } + + if ($DRY_RUN) { + info "[dry run] $($python.Exe) $($python.Arguments -join ' ') $installer $($arguments -join ' ')" + exit 0 + } + + & $python.Exe @($python.Arguments) $installer @arguments + exit $LASTEXITCODE +} + +main diff --git a/host-setup/windows/install-tools.ps1 b/host-setup/windows/install-tools.ps1 index cf7dab8d..1fb7c2b0 100644 --- a/host-setup/windows/install-tools.ps1 +++ b/host-setup/windows/install-tools.ps1 @@ -1,896 +1,896 @@ -# Installs and upgrades the host tools the fleet's repositories expect, on native Windows, through winget. -# Every tool in the contract has a winget package, so winget is the only source here, where the Linux script needs three because an apt feed trails upstream on half its tools. -# No version is written into this script, and winget is asked what each package carries now, so the script does not go stale between releases. -# -# Every step is idempotent. -# A package is installed only where winget reports none, and upgraded only where the installed version differs from what the source carries. -# Re-running repairs drift rather than assuming a clean host. -# -# Nothing here elevates, and no scope is passed unless the caller names one. -# An installer that needs administrator raises its own prompt, which is the path with the fewest failures: forcing user scope installs a second copy beside a machine wide one, and some installers fail outright when launched from an already elevated process. - -# CmdletBinding with an explicit position on the tool list is what keeps every other parameter named only. -# Without it a stray word binds to the first parameter that takes a value, and a mistyped tool name is reported against -Scope instead. -[CmdletBinding()] -param( - [Alias('r')][switch]$Report, - [Alias('i')][switch]$Install, - [Alias('u')][switch]$Upgrade, - [switch]$Reinstall, - [Alias('l')][switch]$List, - [Alias('n')][switch]$DryRun, - [Alias('y')][switch]$Yes, - [Alias('o')][switch]$Optional, - [ValidateSet('user', 'machine')][string]$Scope, - [Alias('h')][switch]$Help, - [Parameter(Position = 0, ValueFromRemainingArguments)][string[]]$Name -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' -# A non-zero exit from winget is an answer here rather than a failure. -# Setting this keeps a profile that turned it on from turning every read into a terminating error. -$PSNativeCommandUseErrorActionPreference = $false - -# Returned by winget when nothing matches the query, which is an answer rather than a failure. -$NOT_FOUND = -1978335212 - -# Every parameter is read into a variable here rather than from inside a function. -# A script parameter reached only from a nested scope reads as declared and never used, which is a finding on each one and hides a parameter that genuinely is unused. -$ACTIONS = [ordered]@{ - report = [bool]$Report - install = [bool]$Install - upgrade = [bool]$Upgrade - reinstall = [bool]$Reinstall - list = [bool]$List -} -$WANT_HELP = [bool]$Help -# Filtered rather than wrapped, because wrapping an unset parameter yields a one element list holding nothing, which reads as one tool named the empty string. -$WANT_TOOLS = @($Name | Where-Object { $_ }) - -$MODE = 'report' -$DRY_RUN = [bool]$DryRun -$ASSUME_YES = [bool]$Yes -$WITH_OPTIONAL = [bool]$Optional -$WANT_SCOPE = $Scope -$ELEVATED = $false -$SELECTED = @() -$NOTES = @() -$FAILED = @() -$CHANGED = @() -$EXPLICIT = $null - -# --- Output --- - -function log { param([string]$Message = '') Write-Host $Message } -function info { param([string]$Message) Write-Host " $Message" } -function step { param([string]$Message) Write-Host "`n==> $Message" } -function warn { param([string]$Message) [Console]::Error.WriteLine("WARNING: $Message") } -function die { param([string]$Message) [Console]::Error.WriteLine("ERROR: $Message"); exit 1 } - -function note { param([string]$Tool, [string]$Message) $script:NOTES += "${Tool}: $Message" } - -# A path with the home directory replaced by the variable that names it. -# A report is written to be pasted into an issue or a pull request, so a path it prints carries the account name into wherever it is pasted, and the comments here already avoid writing one for the same reason. -# The variable is what a reader expands themselves, so the path stays as actionable as it was. -function Hide-Home { - param([string]$Path) - if (-not $Path -or -not $HOME) { return $Path } - if ($Path.StartsWith($HOME, [StringComparison]::OrdinalIgnoreCase)) { - return '%USERPROFILE%' + $Path.Substring($HOME.Length) - } - return $Path -} - -function usage { - # The closing marker of a here-string has to sit at column 0, so this block is deliberately unindented. - Write-Host @' -Usage: install-tools.ps1 [options] [tool ...] - -Installs the host tools the fleet's repositories expect, from winget, which is the only source -every one of them has. With no tool named, every managed tool is selected. - -Actions, name one, default -Report: - -r, -Report Report installed and available versions, change nothing - -i, -Install Install what is missing, leave an installed tool at its version - -u, -Upgrade Install what is missing and upgrade what is behind - -Reinstall Remove the installed copy, then install it again - -l, -List List the managed tools and their winget package ids - -h, -Help Show this help - -Options: - -n, -DryRun Print the commands instead of running them - -y, -Yes Do not prompt before changing the host - -o, -Optional Include the optional package set, where a tool has one - -Scope Name a scope, either user or machine, for the copy to act on - -Run this without elevation. No scope is passed unless -Scope names one, so winget acts on the copy -it finds and an installer that needs administrator asks for it itself. Naming a scope that -disagrees with the installed copy would add a second copy beside the first rather than replacing -it, so an upgrade refuses that case and names -Reinstall, which removes the old copy first. - -An install, upgrade or reinstall of docker also brings the WSL platform itself up to Docker -Desktop's own floor where it is behind, and stops Docker Desktop first either way, since Docker -Desktop holds the WSL service open and both a platform update and its own WSL integration -otherwise need a restart to recover from an engine bump. wsl --update raises its own administrator -prompt, which this refuses to start unattended where nothing could answer it, rather than hang. - -Examples: - install-tools.ps1 Report on every tool - install-tools.ps1 -Install Install what is missing - install-tools.ps1 -Upgrade -Yes Bring every tool current, no prompt - install-tools.ps1 -Upgrade uv jq Bring two tools current - install-tools.ps1 -Install -Optional dotnet - install-tools.ps1 -Upgrade -DryRun Show what an upgrade would run - install-tools.ps1 -Reinstall jq -Scope machine -'@ -} - -# --- Host --- - -# Read the host identity, and refuse a host this script cannot install for. -# A host that cannot run this script cannot be repaired by it, so each refusal prints the one command that fixes it. -function Test-HostSupported { - if ($PSVersionTable.PSVersion.Major -lt 7) { - die "This script needs PowerShell 7 or later, and this is $($PSVersionTable.PSVersion). Install it with: winget install --id Microsoft.PowerShell --exact --source winget" - } - if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { - die 'winget not found, and this script installs winget packages. Install App Installer from the Microsoft Store, then run this again.' - } - $identity = [Security.Principal.WindowsIdentity]::GetCurrent() - $script:ELEVATED = ([Security.Principal.WindowsPrincipal]$identity).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) -} - -# --- Execution --- - -# Run a command, or print it under -DryRun. -# A read used to decide what to do runs either way, and only a command that changes the host goes through here. -function run { - param([Parameter(Mandatory)][string]$Command, [Parameter(ValueFromRemainingArguments)][string[]]$Arguments) - if ($script:DRY_RUN) { - Write-Host " [dry run] $Command $($Arguments -join ' ')" - return 0 - } - # The command's own output goes to the console rather than down the pipeline. - # A native command writes to this function's output stream, so without this the caller receives every line the command printed with the exit code appended, and a check against 0 then compares against the first line of output. - & $Command @Arguments | Out-Host - return $LASTEXITCODE -} - -function confirm { - param([Parameter(Mandatory)][string]$Question) - if ($script:ASSUME_YES -or $script:DRY_RUN) { return $true } - # Both are checked because a scheduled task reports one and not the other, and either alone misses a case. - if (-not [Environment]::UserInteractive -or [Console]::IsInputRedirected) { - die 'Not a terminal and -Yes was not given, refusing to change the host unattended' - } - return ((Read-Host "$Question [y/N]") -match '^(y|yes)$') -} - -# --- winget --- - -# The version column of every row winget printed for one id. -# The offsets come from the header rather than from a whitespace split, because a package name carries spaces and splitting on them moves the version into the name. -# A row is kept only where the id column holds the id that was asked for, which drops the trailing count line without a list of strings to ignore. -# Every list returning function here returns with a leading comma, which is what stops PowerShell unrolling a one element array into the element. -# Without it a single installed version arrives as a string, and asking a string for its Count is an error under Set-StrictMode rather than the 1 the caller expects. -function Read-WingetTable { - param([string]$Text, [string]$Id) - $rows = @() - $lines = $Text -split "`r?`n" - $header = $lines | Where-Object { $_ -match '^Name\s+Id\s+Version' } | Select-Object -First 1 - if (-not $header) { return , $rows } - $idColumn = $header.IndexOf('Id') - $versionColumn = $header.IndexOf('Version') - foreach ($line in $lines) { - if ($line.Length -le $versionColumn) { continue } - if (-not $line.Substring($idColumn).StartsWith($Id)) { continue } - $rows += ($line.Substring($versionColumn) -split '\s+')[0] - } - return , $rows -} - -# The id and version of every row winget printed for a source search, unfiltered by id. -# Shares the header driven offsets with Read-WingetTable, but keeps the id column instead of assuming the caller already knows it, which is what Resolve-ToolPackage needs from a family search. -function Read-WingetSearchTable { - param([string]$Text, [string]$Prefix) - $rows = @() - $lines = $Text -split "`r?`n" - $header = $lines | Where-Object { $_ -match '^Name\s+Id\s+Version' } | Select-Object -First 1 - if (-not $header) { return , $rows } - $idColumn = $header.IndexOf('Id') - $versionColumn = $header.IndexOf('Version') - foreach ($line in $lines) { - if ($line.Length -le $versionColumn) { continue } - $id = ($line.Substring($idColumn, $versionColumn - $idColumn)).Trim() - if (-not $id.StartsWith($Prefix)) { continue } - $version = ($line.Substring($versionColumn) -split '\s+')[0] - $rows += [PSCustomObject]@{ Id = $id; Version = $version } - } - return , $rows -} - -# The installed versions of one package id, or an empty list where none is installed. -# The exit code decides rather than the output text, since a missing id and an unreadable source both print prose and only the code tells them apart. -# A null answer means the question could not be answered, which is not the same as none installed. -function Get-WingetInstalled { - param([Parameter(Mandatory)][string]$Id, [string]$InScope) - $arguments = @('list', '--id', $Id, '--exact', '--source', 'winget', '--disable-interactivity') - if ($InScope) { $arguments += @('--scope', $InScope) } - $text = (& winget @arguments 2>&1 | Out-String -Width 500) - if ($LASTEXITCODE -eq $script:NOT_FOUND) { return , @() } - if ($LASTEXITCODE -ne 0) { return $null } - return , (Read-WingetTable -Text $text -Id $Id) -} - -# One version for a package winget lists more than once, or nothing where the rows do not describe one product. -# Rows sharing a major version are side by side builds of one product and the newest is the answer, which is what a dotnet SDK line looks like. -# Rows whose majors differ are two products sharing an id, which is what the legacy WSL installer looks like beside WSL itself, and there no single version compares. -function Resolve-InstalledVersion { - param([string[]]$Version) - if (-not $Version -or $Version.Count -eq 0) { return $null } - if ($Version.Count -eq 1) { return $Version[0] } - $majors = @($Version | ForEach-Object { ($_ -split '\.')[0] } | Sort-Object -Unique) - if ($majors.Count -ne 1) { return $null } - - # Compared component by component rather than sorted, because Sort-Object orders a version as text and 10.0.9 then outranks 10.0.10. - # Three side by side dotnet builds hid this, since 110, 204 and 302 are all three digits and sort the same either way. - $newest = $Version[0] - foreach ($candidate in $Version) { - if ((Compare-HostVersion $candidate $newest) -gt 0) { $newest = $candidate } - } - return $newest -} - -# What the source carries now, read without installing anything. -# The show command prints one Version line even for an id the list command answers with several rows, which is what makes it the reader for the target rather than a second list call. -function Get-WingetAvailable { - param([Parameter(Mandatory)][string]$Id) - $text = (& winget show --id $Id --exact --source winget --disable-interactivity 2>&1 | Out-String -Width 500) - if ($LASTEXITCODE -ne 0) { return $null } - if ($text -match '(?m)^Version:\s+(\S+)\s*$') { - if ($Matches[1] -eq 'Unknown') { return $null } - return $Matches[1] - } - return $null -} - -# Which scopes carry a copy, as a sorted list. -# Two probes rather than one reading, because winget reports no scope column and a package installed in both scopes is the case worth catching. -function Get-WingetScope { - param([Parameter(Mandatory)][string]$Id) - $found = @() - foreach ($candidate in 'user', 'machine') { - $rows = Get-WingetInstalled -Id $Id -InScope $candidate - if ($null -ne $rows -and $rows.Count -gt 0) { $found += $candidate } - } - return , $found -} - -# Whether winget wrote the uninstall entry for this package itself, which it does for a portable or an archive package and not for an exe or an msi. -# This is the only positive evidence of provenance available, and its absence proves nothing: winget runs the vendor's own installer for an exe or an msi, so that entry is identical whether winget invoked it or a person did. -# Reported where present and silent where not, rather than being turned into a claim it cannot support. -function Test-WingetOwnedEntry { - param([Parameter(Mandatory)][string]$Id) - $suffix = '_Microsoft.Winget.Source_8wekyb3d8bbwe' - $roots = @( - 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' - 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' - 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall' - ) - foreach ($root in $roots) { - if (Test-Path (Join-Path $root ($Id + $suffix))) { return $true } - } - return $false -} - -# The package ids winget refuses to move as part of an upgrade of everything. -# A package lands here because its manifest asks for it, which is the author saying the application updates itself, and the version winget reports is then the one the installer first wrote rather than the one the application runs. -# Read once and cached, since it costs a full upgrade query. -function Get-ExplicitUpgrade { - if ($null -ne $script:EXPLICIT) { return , $script:EXPLICIT } - $script:EXPLICIT = @() - $text = (& winget upgrade --include-unknown --disable-interactivity 2>&1 | Out-String -Width 500) - if ($LASTEXITCODE -ne 0) { return $script:EXPLICIT } - $marker = 'require explicit targeting for upgrade' - $index = $text.IndexOf($marker) - if ($index -lt 0) { return $script:EXPLICIT } - $tail = $text.Substring($index) - $lines = $tail -split "`r?`n" - $header = $lines | Where-Object { $_ -match '^Name\s+Id\s+Version' } | Select-Object -First 1 - if (-not $header) { return $script:EXPLICIT } - $idColumn = $header.IndexOf('Id') - $versionColumn = $header.IndexOf('Version') - foreach ($line in $lines) { - if ($line.Length -le $versionColumn) { continue } - if ($line -match '^Name\s+Id\s+Version') { continue } - $script:EXPLICIT += ($line.Substring($idColumn, $versionColumn - $idColumn)).Trim() - } - return , $script:EXPLICIT -} - -# The package id winget's own catalog says produced the version this tool reports as installed. -# `winget list --id X --exact` answers yes for a package sharing this tool's family even where X itself is not what is installed (#693: a host running OpenJS.NodeJS, the Current channel, reads as OpenJS.NodeJS.LTS because the two share a publisher and a name), but the version it reports comes straight from the installed entry regardless of which id was asked about, so matching that version against every id in the family, read from a plain source search rather than an id scoped one, is what tells the ids apart without trusting the id column winget answered with at all. -# A tool naming no family resolves to its own package unchanged and costs nothing extra, and a version matching none of the family's ids does the same, since a family search that cannot confirm anything is no worse than not asking. -# A version matching more than one id falls back the same way rather than picking the first: two ids sharing a version happens right at a channel handoff, when a major that just left Current has not yet lost its old id from the search results, and picking between them by list order would be a guess wearing the shape of an answer. -function Resolve-ToolPackage { - param([Parameter(Mandatory)][hashtable]$Tool, [string]$Installed) - if (-not $Tool.Family -or -not $Installed) { return $Tool.Package } - $text = (& winget search --query $Tool.Family --source winget --disable-interactivity 2>&1 | Out-String -Width 500) - if ($LASTEXITCODE -ne 0) { return $Tool.Package } - $found = @((Read-WingetSearchTable -Text $text -Prefix $Tool.Family) | Where-Object { $_.Version -eq $Installed }) - if ($found.Count -eq 1) { return $found[0].Id } - return $Tool.Package -} - -# Install, upgrade and remove, named here so the flags appear once in this file. -# No scope is passed unless the caller named one, because winget then acts on the copy it found and naming a different scope adds a copy rather than replacing one. -function Invoke-WingetInstall { - param([Parameter(Mandatory)][string]$Id) - $arguments = @('install', '--id', $Id, '--exact', '--source', 'winget', '--disable-interactivity', - '--accept-source-agreements', '--accept-package-agreements', '--silent') - if ($script:WANT_SCOPE) { $arguments += @('--scope', $script:WANT_SCOPE) } - return (run 'winget' @arguments) -} - -# An upgrade takes the named scope too, on the same rule as an install. -# Reaching here with a scope that disagrees with the installed copy is already refused, so the scope named here is one a copy sits in, and passing it is what says which copy to move where a tool is installed in both. -function Invoke-WingetUpgrade { - param([Parameter(Mandatory)][string]$Id) - $arguments = @('upgrade', '--id', $Id, '--exact', '--source', 'winget', '--disable-interactivity', - '--accept-source-agreements', '--accept-package-agreements', '--silent', '--include-unknown') - if ($script:WANT_SCOPE) { $arguments += @('--scope', $script:WANT_SCOPE) } - return (run 'winget' @arguments) -} - -# The scope to remove from is the one the copy was found in, which the caller passes, rather than the one the caller asked to end up in. -# Removing in the requested scope finds nothing where the copy sits in the other one, so the old copy survives and the install that follows adds a second beside it, which is the state -Reinstall exists to clear. -function Invoke-WingetRemove { - param([Parameter(Mandatory)][string]$Id, [string]$InScope) - $arguments = @('uninstall', '--id', $Id, '--exact', '--disable-interactivity', '--silent') - if ($InScope) { $arguments += @('--scope', $InScope) } - return (run -Command 'winget' -Arguments $arguments) -} - -# --- Tools --- - -# Managed tools, in the order a report lists them. -# Every one is a single winget package, which is what makes this a table where the Linux script needs four functions per tool. -# Probe names the executable that proves the tool is present when winget knows no package for it, and it is py for python because that is the name a correctly set up Windows host carries. -# Family names the id prefix winget's catalog shares across every channel and pinned major a tool ships under, and is empty everywhere but node: node alone ships as more than one id (Current, LTS, and one per pinned major back to 4), any of which is fine on a host as long as its version clears Available, and Resolve-ToolPackage is what finds which one that is. -$TOOLS = @( - @{ Name = 'git'; Package = 'Git.Git'; Probe = 'git'; Optional = @(); Family = '' } - @{ Name = 'gh'; Package = 'GitHub.cli'; Probe = 'gh'; Optional = @(); Family = '' } - @{ Name = 'jq'; Package = 'jqlang.jq'; Probe = 'jq'; Optional = @(); Family = '' } - @{ Name = 'python'; Package = 'Python.Python.3.13'; Probe = 'py'; Optional = @(); Family = '' } - @{ Name = 'uv'; Package = 'astral-sh.uv'; Probe = 'uv'; Optional = @(); Family = '' } - @{ Name = 'docker'; Package = 'Docker.DockerDesktop'; Probe = 'docker'; Optional = @(); Family = '' } - @{ Name = 'node'; Package = 'OpenJS.NodeJS.LTS'; Probe = 'node'; Optional = @(); Family = 'OpenJS.NodeJS' } - @{ Name = 'dotnet'; Package = 'Microsoft.DotNet.SDK.10'; Probe = 'dotnet'; Optional = @('Microsoft.DotNet.SDK.9', 'Microsoft.DotNet.SDK.8'); Family = '' } -) - -function Get-Tool { - param([Parameter(Mandatory)][string]$ToolName) - return ($script:TOOLS | Where-Object { $_.Name -eq $ToolName } | Select-Object -First 1) -} - -# --- Status --- - -# A version as a comparable list of integers, with anything non numeric dropped. -# Mirrors the gate's own comparison so a host reads the same either side of it. -function Get-VersionKey { - param([string]$Version) - $parts = @() - foreach ($part in ($Version -split '[._-]')) { - if ($part -match '^\d+$') { $parts += [int]$part } else { break } - } - if ($parts.Count -eq 0) { return , @(0) } - return , $parts -} - -# Compare two versions, padding the shorter with zeros so more components alone does not read as newer. -function Compare-HostVersion { - param([string]$Left, [string]$Right) - $a = Get-VersionKey $Left - $b = Get-VersionKey $Right - for ($i = 0; $i -lt [Math]::Max($a.Count, $b.Count); $i++) { - $x = if ($i -lt $a.Count) { $a[$i] } else { 0 } - $y = if ($i -lt $b.Count) { $b[$i] } else { 0 } - if ($x -lt $y) { return -1 } - if ($x -gt $y) { return 1 } - } - return 0 -} - -# Everything a report and an apply both need about one tool, read once. -function Get-ToolState { - param([Parameter(Mandatory)][hashtable]$Tool) - $rows = Get-WingetInstalled -Id $Tool.Package - $state = @{ - # The id acted on from here down. - # Reading rows above already answered whether something from this tool's family is installed and at what version, and this may still change below, to the id that version actually belongs to, before anything here is reported or acted on. - Package = $Tool.Package - Installed = $null - Rows = @() - # Whether the installed state was read at all, kept apart from what it said. - # Folding a failed read into an empty list would report a tool whose state is unknown as one that is not installed, and an install would then run against a host nobody measured. - Readable = ($null -ne $rows) - Available = (Get-WingetAvailable -Id $Tool.Package) - Scope = @() - Status = 'unknown' - } - if ($state.Readable) { - $state.Rows = $rows - $state.Installed = Resolve-InstalledVersion -Version $rows - $state.Package = Resolve-ToolPackage -Tool $Tool -Installed $state.Installed - if ($rows.Count -gt 0) { $state.Scope = Get-WingetScope -Id $state.Package } - } - $state.Status = Get-ToolStatus -Tool $Tool -State $state - return $state -} - -# One word for what a tool needs. -# The three Windows meanings sit beside the five the Linux peer carries, and each names a different reason a version comparison would mislead. -function Get-ToolStatus { - param([Parameter(Mandatory)][hashtable]$Tool, [Parameter(Mandatory)][hashtable]$State) - if (-not $State.Readable) { return 'unreadable' } - if ($State.Rows.Count -eq 0) { - # A tool answering on PATH that winget knows no package for is one winget cannot manage at all. - if (Get-Command $Tool.Probe -ErrorAction SilentlyContinue) { return 'unmanaged' } - if (-not $State.Available) { return 'unavailable' } - return 'missing' - } - if (-not $State.Installed) { return 'multiple' } - if (-not $State.Available) { return 'unknown' } - if ((Get-ExplicitUpgrade) -contains $State.Package) { return 'self-updating' } - if ((Compare-HostVersion $State.Installed $State.Available) -ge 0) { return 'current' } - return 'outdated' -} - -# Per tool detail worth a line under the report, rather than a column of its own. -function Add-ToolNote { - param([Parameter(Mandatory)][hashtable]$Tool, [Parameter(Mandatory)][hashtable]$State) - if ($Tool.Name -eq 'python') { - # Written unexpanded, because the expanded form names a real account and the prose gate rejects that. - note 'python' 'python3 resolves to the Microsoft Store alias stub under %LOCALAPPDATA%\Microsoft\WindowsApps, so py -3 is the name this contract uses here' - $resolved = Get-Command python -ErrorAction SilentlyContinue - if ($resolved -and $resolved.Source -notmatch 'Python\d') { - note 'python' "python resolves to $(Hide-Home $resolved.Source), which is not the interpreter winget installed" - } - } - if ($State.Scope.Count -gt 1) { - note $Tool.Name 'installed in both scopes, so one copy shadows the other on PATH, and -Reinstall removes one' - } - if ($State.Rows.Count -gt 0 -and (Test-WingetOwnedEntry -Id $State.Package)) { - note $Tool.Name 'winget wrote this uninstall entry, so winget installed it' - } - if ($State.Status -eq 'multiple') { - note $Tool.Name "winget lists $($State.Rows -join ', ') under one id, and their major versions differ, so no single installed version compares" - } - if ($State.Status -eq 'unmanaged') { - note $Tool.Name 'answers on PATH and winget knows no package for it, so winget cannot upgrade it and -Reinstall does not apply' - } - if ($State.Status -eq 'unreadable') { - note $Tool.Name 'winget did not answer what is installed, so this row reports nothing rather than reporting it as absent' - } - if ($State.Status -eq 'self-updating') { - note $Tool.Name 'updates itself, so the version winget reports is the one it was installed at rather than the one it runs' - } - if ($Tool.Name -eq 'dotnet' -and -not $script:WITH_OPTIONAL) { - note 'dotnet' "optional set not selected: $($Tool.Optional -join ', ')" - } - if ($Tool.Name -eq 'docker') { - $wslProblem = Test-WslReadyForDocker - if ($wslProblem) { note 'docker' $wslProblem } - } -} - -# --- WSL --- - -# Docker Desktop's own documented floor for the WSL platform, per docs.docker.com/desktop/features/wsl. -$DOCKER_WSL_FLOOR = '2.1.5' - -# Every wsl.exe read goes through here, because wsl.exe emits UTF-16 by default and its output then reads as NUL separated characters. -# A wsl.exe call that changes the host instead of reading it goes through Invoke-WslRun below, which applies the same guard to a live streamed run rather than a captured one. -# Mirrored from upgrade-host.ps1 rather than shared with it, on the same rule as the rest of this directory: a script here has to stay independently fetchable. -function Invoke-Wsl { - param([Parameter(ValueFromRemainingArguments)][string[]]$Arguments) - $previous = $env:WSL_UTF8 - try { - $env:WSL_UTF8 = '1' - $text = (& wsl.exe @Arguments 2>&1 | Out-String -Width 500) - if ($text.Contains([char]0)) { $text = $text -replace "`0", '' } - return $text - } finally { - if ($null -eq $previous) { Remove-Item Env:\WSL_UTF8 -ErrorAction SilentlyContinue } - else { $env:WSL_UTF8 = $previous } - } -} - -# The mutation-side counterpart to Invoke-Wsl: runs a wsl.exe command that changes the host through the same run wrapper every other mutating command in this script uses, with the same WSL_UTF8 guard applied around it, so its live streamed output does not read to a person as NUL separated characters either. -function Invoke-WslRun { - param([Parameter(ValueFromRemainingArguments)][string[]]$Arguments) - $previous = $env:WSL_UTF8 - try { - $env:WSL_UTF8 = '1' - return (run 'wsl.exe' @Arguments) - } finally { - if ($null -eq $previous) { Remove-Item Env:\WSL_UTF8 -ErrorAction SilentlyContinue } - else { $env:WSL_UTF8 = $previous } - } -} - -# Just the "WSL version" line, as a value Compare-HostVersion can read. -# Get-WslVersion in upgrade-host.ps1 concatenates WSL, kernel and WSLg into one display string instead, which serves a report rather than a comparison. -function Get-WslPlatformVersion { - if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { return $null } - $text = Invoke-Wsl '--version' - if ($LASTEXITCODE -ne 0) { return $null } - if ($text -match '(?m)^WSL version:\s*(\S+)\s*$') { return $Matches[1] } - return $null -} - -# What stands between this host and installing or upgrading docker, or $null where nothing does. -# A pure report either way, used as-is for -Report's note and as Enter-DockerMaintenance's own pre-check below, which is what actually drives the WSL platform update this used to only name. -# Standing up WSL from nothing with wsl --install stays a person's own step regardless, since that is a different action on a different subject from bringing an existing platform current, the same reasoning that keeps distribution installs in setup-wsl.ps1 rather than folded in here. -function Test-WslReadyForDocker { - if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { - return "wsl.exe was not found, and Docker Desktop needs WSL2. Install it with: wsl --install --no-distribution" - } - $version = Get-WslPlatformVersion - if (-not $version) { - return "the WSL version could not be read, and Docker Desktop needs WSL $($script:DOCKER_WSL_FLOOR) or later. Update it with: host-setup\windows\upgrade-host.ps1 -Wsl" - } - if ((Compare-HostVersion $version $script:DOCKER_WSL_FLOOR) -lt 0) { - return "WSL is at $version, and Docker Desktop needs $($script:DOCKER_WSL_FLOOR) or later. Update it with: host-setup\windows\upgrade-host.ps1 -Wsl" - } - return $null -} - -# --- Docker --- - -# The processes that mean Docker Desktop is up when its own CLI cannot answer, mirrored from upgrade-host.ps1's Get-DockerProcess rather than shared with it, on the same rule as the rest of this directory: a script here has to stay independently fetchable. -$DOCKER_PROCESSES = @('Docker Desktop', 'com.docker.backend', 'com.docker.build') - -# Whether Docker Desktop answers as running right now, preferring its own CLI over a process probe, since the CLI is what agrees with what the tray icon shows and a vmmem process surviving a crash is not the same as the backend actually being up. -# Falls back to the process probe where the CLI itself cannot answer (missing from PATH, or the status call failing), rather than reading either as "not running" outright: that reading is what let Enter-DockerMaintenance run wsl --update against a Docker Desktop this function wrongly reported as already down. -function Test-DockerDesktopRunning { - if (Get-Command docker -ErrorAction SilentlyContinue) { - $text = (& docker desktop status --format json 2>&1 | Out-String) - if ($LASTEXITCODE -eq 0) { - # Parsed rather than matched, the same way setup-wsl.ps1 reads Docker's own settings file, with the regex kept as a fallback for a CLI version that ever answers non-JSON rather than reading that as "not running". - try { - return ((ConvertFrom-Json $text -ErrorAction Stop).Status -eq 'running') - } catch { - return ($text -match '"Status"\s*:\s*"running"') - } - } - } - return [bool](Get-Process -Name $script:DOCKER_PROCESSES -ErrorAction SilentlyContinue) -} - -function Stop-DockerDesktop { - # Test-DockerDesktopRunning can answer true through the process probe alone, with the docker CLI missing or off PATH, and & docker there is a command-not-found PowerShell throws rather than a native exit code this can warn on, so this checks first rather than let that surface as a crash. - if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { - warn 'docker desktop stop needs the docker CLI, which is not on PATH; stop Docker Desktop by hand from its tray icon' - return 1 - } - info 'Stopping Docker Desktop' - $code = run 'docker' @('desktop', 'stop', '--timeout', '90') - if ($code -ne 0) { warn "docker desktop stop exited $code" } - return $code -} - -function Start-DockerDesktop { - if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { - warn 'docker desktop start needs the docker CLI, which is not on PATH; start Docker Desktop by hand and check WSL integration per distro' - return 1 - } - info 'Starting Docker Desktop' - $code = run 'docker' @('desktop', 'start', '--timeout', '180') - if ($code -ne 0) { warn 'docker desktop start did not exit cleanly, start Docker Desktop by hand and check WSL integration per distro' } - return $code -} - -# Everything a docker install, upgrade or reinstall needs from Docker Desktop and WSL before winget touches the package is folded into one window rather than run twice: Docker Desktop holds the WSL service open, so a platform update fails part way while it is running, the same reason upgrade-host.ps1 -Wsl refuses outright. -# Docker Desktop's own per distro WSL integration also goes stale across an engine bump often enough that it has a name on Docker's own tracker, surfacing as "WSL integration with distro '<name>' unexpectedly stopped" the next time anything in that distro touches docker, and the in app "Restart the WSL integration" button on that dialog does not clear it, since it retries the proxy inside the distro that is already running against the same stale state. -# Both are fixed the same way where Docker Desktop is running to begin with: stopped, WSL brought current, every distro shut down with it so each one's integration remounts fresh rather than being patched in place, and Docker Desktop started again once the docker package itself is also settled, by Exit-DockerMaintenance below; a run that finds Docker Desktop already stopped has nothing to restore and none of that runs. -# Boundary note: this script used to be read-only on WSL by design, naming upgrade-host.ps1 -Wsl as a person's own step rather than running it. -# The maintainer moved that boundary once a docker version bump on its own already needed this same stop, then restart, window for its own integration to recover, since a platform update asks for nothing more than that same window with Docker Desktop already stopped inside it. -# Returns whether docker is clear to proceed, and whether this stopped Docker Desktop, which is what Exit-DockerMaintenance needs to know whether it owes a restart. -function Enter-DockerMaintenance { - param([Parameter(Mandatory)][bool]$WasRunning, [string]$WslProblem) - - $result = @{ Proceed = $true; Stopped = $false } - if (-not $WasRunning -and -not $WslProblem) { return $result } - - # Test-WslReadyForDocker's own string carries a remedy clause for a caller that will not fix WSL itself, "Update it with: host-setup\windows\upgrade-host.ps1 -Wsl", which reads as misleading and duplicative embedded in a prompt for a run about to do exactly that. - $wslReason = if ($WslProblem) { $WslProblem -replace '\s*Update it with:.*$', '' } else { $WslProblem } - $question = if ($WslProblem -and $WasRunning) { - "docker needs WSL updated first ($wslReason). Stop Docker Desktop, update WSL, and restart Docker Desktop to continue?" - } elseif ($WslProblem) { - "docker needs WSL updated first ($wslReason). Update WSL to continue?" - } else { - "docker is about to be upgraded or reinstalled, and Docker Desktop's own WSL integration commonly goes stale across a change like that. Stop Docker Desktop first, and restart it after, to avoid that?" - } - if (-not (confirm $question)) { - if ($WslProblem) { $result.Proceed = $false; return $result } - note 'docker' 'WSL integration may report a stale per-distro error until Docker Desktop and WSL are both restarted (docker desktop stop; wsl --shutdown; docker desktop start); declined the automatic cycle' - return $result - } - - # Gated on whether Docker Desktop is still observed running after the attempt, not on Stop-DockerDesktop's own exit code: a non-zero exit that still left Docker Desktop stopped is not a reason to refuse the run and, just as importantly, not a reason to leave Stopped unset, which would tell Exit-DockerMaintenance below there is nothing to restart and strand the host stopped when it started running. - # Skipped under -DryRun, where nothing actually stopped and Docker Desktop is still genuinely running, which would otherwise read every dry run on a live host as this exact failure rather than as a preview. - if ($WasRunning) { - Stop-DockerDesktop | Out-Null - if (-not $script:DRY_RUN -and (Test-DockerDesktopRunning)) { - warn 'docker skipped, Docker Desktop did not stop, and a WSL platform update fails part way while it is running' - $result.Proceed = $false - return $result - } - $result.Stopped = $true - } - if ($WslProblem) { - # Installing through wsl --update's own MSI raises a UAC prompt when this pwsh is not itself elevated, the state the rest of this script deliberately stays in (running elevated trades this one prompt for winget installers elsewhere that fail pre-elevated instead). - # Nothing can answer that prompt without a person at the console, so a run with neither would hang rather than fail, which -Yes is supposed to rule out, not walk into unattended. - if (-not $script:ELEVATED -and (-not [Environment]::UserInteractive -or [Console]::IsInputRedirected)) { - # Built rather than a fixed string, because whether this refusal itself restarts Docker Desktop depends on whether $result.Stopped is set, and a run that found it already stopped restarts nothing for the remedy below to run into. - $restartNote = if ($result.Stopped) { ' This refusal also restarts Docker Desktop, to leave the host as it found it, so it may already be running again by the time this is read.' } else { '' } - warn "wsl --update needs administrator and raises its own prompt, which nothing can answer unattended; it would hang rather than fail, so this refuses to start it. Run interactively once so the prompt has someone to answer, or quit Docker Desktop yourself and run host-setup\windows\upgrade-host.ps1 -Wsl, which refuses on its own while Docker Desktop is running.$restartNote" - if ($result.Stopped) { Start-DockerDesktop | Out-Null; $result.Stopped = $false } - $result.Proceed = $false - return $result - } - info 'Updating the WSL platform' - info 'This restarts every distribution, so anything running inside one is stopped' - $updateCode = Invoke-WslRun '--update' - if ($updateCode -ne 0) { warn "wsl --update exited $updateCode" } - # Skipped under -DryRun on the same rule as the stop check above: wsl --update never ran, so WSL is still genuinely behind, and re-checking it here would read every dry run as this same failure rather than as a preview. - $stillBroken = if ($script:DRY_RUN) { $null } else { Test-WslReadyForDocker } - if ($stillBroken) { - warn "docker skipped, still $stillBroken after the update" - if ($result.Stopped) { Start-DockerDesktop | Out-Null; $result.Stopped = $false } - $result.Proceed = $false - return $result - } - } - return $result -} - -# The other half of Enter-DockerMaintenance, run once the docker package itself is settled: shuts every WSL distro down so each one's integration remounts fresh, then starts Docker Desktop again. -# A no-op where Enter-DockerMaintenance never stopped anything, so a run that changed nothing about Docker Desktop does not restart it for no reason, and a run that was declined does not either. -function Exit-DockerMaintenance { - param([Parameter(Mandatory)][bool]$Stopped) - if (-not $Stopped) { return } - info 'Shutting down WSL' - $shutdownCode = Invoke-WslRun '--shutdown' - if ($shutdownCode -ne 0) { warn "wsl --shutdown exited $shutdownCode" } - Start-DockerDesktop | Out-Null -} - -# --- Actions --- - -function Show-List { - $format = '{0,-10} {1,-24} {2}' - log ($format -f 'TOOL', 'PACKAGE', 'OPTIONAL') - foreach ($tool in $script:SELECTED) { - $record = Get-Tool $tool - $optional = if ($record.Optional.Count -gt 0) { $record.Optional -join ', ' } else { '-' } - log ($format -f $record.Name, $record.Package, $optional) - } -} - -function Show-Report { - $format = '{0,-10} {1,-16} {2,-16} {3,-24} {4,-13} {5}' - log ($format -f 'TOOL', 'INSTALLED', 'AVAILABLE', 'SOURCE', 'SCOPE', 'STATUS') - - foreach ($tool in $script:SELECTED) { - $record = Get-Tool $tool - $state = Get-ToolState -Tool $record - # Every row is printed only where they did not resolve to one version, since a dotnet line carrying three side by side builds resolves cleanly and listing all three would overflow the column for nothing. - $installed = if ($state.Status -eq 'multiple') { $state.Rows -join ',' } elseif ($state.Installed) { $state.Installed } else { '-' } - $available = if ($state.Available) { $state.Available } else { '-' } - $scope = if ($state.Scope.Count -gt 0) { $state.Scope -join '+' } else { '-' } - log ($format -f $record.Name, $installed, $available, $state.Package, $scope, $state.Status) - Add-ToolNote -Tool $record -State $state - } - - if ($script:ELEVATED) { - note 'report' 'this pwsh is elevated, and some installers fail when launched from an elevated process, so an unelevated run is the one to prefer' - } - - if ($script:NOTES.Count -eq 0) { return } - log '' - log 'Notes:' - foreach ($entry in $script:NOTES) { info $entry } -} - -# Install, upgrade or reinstall one tool. -# A tool whose install returns non-zero is collected rather than fatal, so one failure does not strand the rest of the run. -# A refusal is not a failure and does end the run: a declined prompt, or a scope that disagrees with the installed copy, stops everything rather than being collected, because continuing past either would install a copy nobody asked for. -function Invoke-ToolApply { - param([Parameter(Mandatory)][string]$ToolName) - $record = Get-Tool $ToolName - $state = Get-ToolState -Tool $record - # The id fresh work goes to, which is the installed id itself except in exactly one case: an outdated tool resolved to an id outside the tool's own default, which for node means a pinned major that is frozen and can never itself clear Available, so the default is the only id capable of fixing it. - $target = if ($state.Status -eq 'outdated' -and $state.Package -ne $record.Package) { $record.Package } else { $state.Package } - - if ($state.Status -eq 'unmanaged') { - log "${ToolName}: answers on PATH and winget knows no package for it, leaving it alone" - return - } - - # Collected rather than fatal, on the same rule as a failed install, and never installed past. - # Installing against a state nobody could read is how a second copy lands beside a first one that was there all along. - if ($state.Status -eq 'unreadable') { - warn "$ToolName skipped, winget did not answer what is installed and this will not install against an unknown state" - $script:FAILED += $ToolName - return - } - - # Naming a scope the installed copy does not sit in would add a second copy beside it, so the removal is asked for rather than done on the way past. - if ($script:WANT_SCOPE -and $state.Rows.Count -gt 0 -and $state.Scope.Count -gt 0 -and - $state.Scope -notcontains $script:WANT_SCOPE -and $script:MODE -ne 'reinstall') { - die "${ToolName}: installed $($state.Scope -join ' and ') wide at $($state.Installed), and -Scope $($script:WANT_SCOPE) was given. Installing would add a second copy beside it. Remove the existing copy first with: install-tools.ps1 -Reinstall $ToolName -Scope $($script:WANT_SCOPE)" - } - - if ($script:MODE -eq 'reinstall') { - if ($state.Rows.Count -eq 0) { - log "${ToolName}: not installed, so there is nothing to remove" - } else { - $where = if ($state.Scope.Count -gt 0) { " installed $($state.Scope -join ' and ') wide" } else { '' } - $again = if ($target -eq $state.Package) { 'install it again' } else { "install $target instead" } - if (-not (confirm "Remove $($state.Package) at $($state.Rows -join ', ')$where and $($again)?")) { - die 'Declined' - } - # Every copy is removed, each in the scope it was found in, since a tool present in both scopes is exactly the shadowing this action exists to clear. - # An empty scope means winget reported none, and there the removal names none either and lets winget act on what it finds. - $found = if ($state.Scope.Count -gt 0) { $state.Scope } else { @('') } - foreach ($scope in $found) { - if ((Invoke-WingetRemove -Id $state.Package -InScope $scope) -ne 0) { - warn "$ToolName failed to uninstall$(if ($scope) { " the $scope wide copy" })" - $script:FAILED += $ToolName - return - } - } - } - } elseif ($state.Status -eq 'current') { - log "${ToolName}: current at $($state.Installed), leaving it alone" - return - } elseif ($state.Status -eq 'self-updating') { - log "${ToolName}: updates itself, and winget does not move it" - return - } elseif ($state.Status -eq 'multiple') { - log "${ToolName}: winget lists $($state.Rows -join ', ') under one id, so -Reinstall is the action that resolves it" - return - } elseif (($script:MODE -eq 'upgrade' -or $script:MODE -eq 'install') -and $state.Status -eq 'outdated' -and $target -ne $state.Package) { - # Named ahead of the plain install-mode branch below, since -Upgrade would hit this same fixed-release wall, so pointing -Install at -Upgrade here would only trade one dead end for another. - log "${ToolName}: $($state.Package) at $($state.Installed) is a fixed release and cannot advance under that id, -Reinstall $ToolName replaces it with $target" - return - } elseif ($script:MODE -eq 'install' -and $state.Status -eq 'outdated') { - log "${ToolName}: at $($state.Installed), the source carries $($state.Available), -Upgrade moves it" - return - } - - # Reached only once real work is about to happen: every "leave it alone" status above already returned. - # That is deliberate here, not incidental: an already current docker with a stale WSL platform has nothing this run needs to fix, and gating on WSL readiness any earlier would skip a tool that was never going to change anyway. - $dockerMaintenance = $null - if ($ToolName -eq 'docker') { - $wasRunning = Test-DockerDesktopRunning - $wslProblem = Test-WslReadyForDocker - if ($wslProblem -and -not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { - warn "docker skipped, $wslProblem" - $script:FAILED += $ToolName - return - } - $dockerMaintenance = Enter-DockerMaintenance -WasRunning $wasRunning -WslProblem $wslProblem - if (-not $dockerMaintenance.Proceed) { - # $wslProblem is empty on the path where Enter-DockerMaintenance refused over a failed Docker Desktop stop rather than a WSL gap, and that reason was already warned there, but a bare "docker skipped, " with nothing after the comma still reads as a broken log line rather than a completed one. - warn "docker skipped$(if ($wslProblem) { ", $wslProblem" } else { ', Docker Desktop maintenance failed, see the warning above' })" - $script:FAILED += $ToolName - return - } - } - - if ($script:MODE -ne 'reinstall') { - log "${ToolName}: $($state.Status)$(if ($state.Available) { ", the source carries $($state.Available)" })" - } - - $packages = @($target) - if ($script:WITH_OPTIONAL) { $packages += $record.Optional } - - foreach ($package in $packages) { - $code = if ($state.Rows.Count -gt 0 -and $script:MODE -eq 'upgrade' -and $package -eq $target) { - Invoke-WingetUpgrade -Id $package - } else { - Invoke-WingetInstall -Id $package - } - if ($code -ne 0) { - warn "$ToolName failed on $package, winget exited $code" - # Windows will not replace a file that is open, and winget reports that as an access denial naming the file rather than whatever holds it. - # The holder is usually the tool itself, left running by an editor or a language server, so the process is named here and the reader is spared guessing at a permission problem that is not one. - $running = @(Get-Process -Name $record.Probe -ErrorAction SilentlyContinue) - if ($running.Count -gt 0) { - info "$($record.Probe) is running as process $($running.Id -join ', '), and Windows cannot replace a running executable" - info 'Close whatever is running it, then run this again' - } - $script:FAILED += $ToolName - # Owed regardless of how this returns: Enter-DockerMaintenance stopped Docker Desktop for this attempt, and a failed install is not a reason to leave it down. - if ($dockerMaintenance) { Exit-DockerMaintenance -Stopped $dockerMaintenance.Stopped } - return - } - } - - $now = Resolve-InstalledVersion -Version (Get-WingetInstalled -Id $target) - if ($now -ne $state.Installed) { - $before = if ($state.Installed) { $state.Installed } else { '-' } - $after = if ($now) { $now } else { '-' } - $script:CHANGED += "$ToolName $before -> $after" - } - if ($dockerMaintenance) { Exit-DockerMaintenance -Stopped $dockerMaintenance.Stopped } -} - -function Invoke-Apply { - log "Selected: $($script:SELECTED -join ' ')" - if ($script:ELEVATED) { - warn 'This pwsh is elevated, and some installers fail when launched from an elevated process. An unelevated run lets each installer ask for administrator only where it needs it.' - } - if (-not (confirm "$($script:MODE) these tools?")) { die 'Declined' } - - foreach ($tool in $script:SELECTED) { - step $tool - Invoke-ToolApply -ToolName $tool - } - - log '' - if ($script:CHANGED.Count -gt 0) { - log 'Changed:' - foreach ($entry in $script:CHANGED) { info $entry } - } else { - log 'Nothing changed' - } - - if ($script:FAILED.Count -gt 0) { - warn "Failed: $($script:FAILED -join ' ')" - return 1 - } - return 0 -} - -# --- Entry --- - -# PowerShell records which switches were given and not the order they came in, so two actions is a refusal rather than the last one winning. -# Refusing is also the better answer: an action silently discarded is one the caller believes ran. -function Resolve-Mode { - $given = @($script:ACTIONS.Keys | Where-Object { $script:ACTIONS[$_] }) - if ($given.Count -gt 1) { die "More than one action given ($($given -join ', ')), name one" } - if ($given.Count -eq 0) { return 'report' } - return $given[0] -} - -function Resolve-Selection { - $names = @($script:TOOLS | ForEach-Object { $_.Name }) - if ($script:WANT_TOOLS.Count -eq 0) { return , $names } - foreach ($candidate in $script:WANT_TOOLS) { - if ($names -notcontains $candidate) { - die "Unknown tool `"$candidate`", -List names the managed tools" - } - } - # Sorted into registry order rather than the order they were typed, so a run reads the same however it was asked for. - return , @($names | Where-Object { $script:WANT_TOOLS -contains $_ }) -} - -function main { - if ($script:WANT_HELP) { usage; exit 0 } - $script:MODE = Resolve-Mode - Test-HostSupported - $script:SELECTED = Resolve-Selection - - switch ($script:MODE) { - 'list' { Show-List; exit 0 } - 'report' { Show-Report; exit 0 } - default { exit (Invoke-Apply) } - } -} - -main +# Installs and upgrades the host tools the fleet's repositories expect, on native Windows, through winget. +# Every tool in the contract has a winget package, so winget is the only source here, where the Linux script needs three because an apt feed trails upstream on half its tools. +# No version is written into this script, and winget is asked what each package carries now, so the script does not go stale between releases. +# +# Every step is idempotent. +# A package is installed only where winget reports none, and upgraded only where the installed version differs from what the source carries. +# Re-running repairs drift rather than assuming a clean host. +# +# Nothing here elevates, and no scope is passed unless the caller names one. +# An installer that needs administrator raises its own prompt, which is the path with the fewest failures: forcing user scope installs a second copy beside a machine wide one, and some installers fail outright when launched from an already elevated process. + +# CmdletBinding with an explicit position on the tool list is what keeps every other parameter named only. +# Without it a stray word binds to the first parameter that takes a value, and a mistyped tool name is reported against -Scope instead. +[CmdletBinding()] +param( + [Alias('r')][switch]$Report, + [Alias('i')][switch]$Install, + [Alias('u')][switch]$Upgrade, + [switch]$Reinstall, + [Alias('l')][switch]$List, + [Alias('n')][switch]$DryRun, + [Alias('y')][switch]$Yes, + [Alias('o')][switch]$Optional, + [ValidateSet('user', 'machine')][string]$Scope, + [Alias('h')][switch]$Help, + [Parameter(Position = 0, ValueFromRemainingArguments)][string[]]$Name +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +# A non-zero exit from winget is an answer here rather than a failure. +# Setting this keeps a profile that turned it on from turning every read into a terminating error. +$PSNativeCommandUseErrorActionPreference = $false + +# Returned by winget when nothing matches the query, which is an answer rather than a failure. +$NOT_FOUND = -1978335212 + +# Every parameter is read into a variable here rather than from inside a function. +# A script parameter reached only from a nested scope reads as declared and never used, which is a finding on each one and hides a parameter that genuinely is unused. +$ACTIONS = [ordered]@{ + report = [bool]$Report + install = [bool]$Install + upgrade = [bool]$Upgrade + reinstall = [bool]$Reinstall + list = [bool]$List +} +$WANT_HELP = [bool]$Help +# Filtered rather than wrapped, because wrapping an unset parameter yields a one element list holding nothing, which reads as one tool named the empty string. +$WANT_TOOLS = @($Name | Where-Object { $_ }) + +$MODE = 'report' +$DRY_RUN = [bool]$DryRun +$ASSUME_YES = [bool]$Yes +$WITH_OPTIONAL = [bool]$Optional +$WANT_SCOPE = $Scope +$ELEVATED = $false +$SELECTED = @() +$NOTES = @() +$FAILED = @() +$CHANGED = @() +$EXPLICIT = $null + +# --- Output --- + +function log { param([string]$Message = '') Write-Host $Message } +function info { param([string]$Message) Write-Host " $Message" } +function step { param([string]$Message) Write-Host "`n==> $Message" } +function warn { param([string]$Message) [Console]::Error.WriteLine("WARNING: $Message") } +function die { param([string]$Message) [Console]::Error.WriteLine("ERROR: $Message"); exit 1 } + +function note { param([string]$Tool, [string]$Message) $script:NOTES += "${Tool}: $Message" } + +# A path with the home directory replaced by the variable that names it. +# A report is written to be pasted into an issue or a pull request, so a path it prints carries the account name into wherever it is pasted, and the comments here already avoid writing one for the same reason. +# The variable is what a reader expands themselves, so the path stays as actionable as it was. +function Hide-Home { + param([string]$Path) + if (-not $Path -or -not $HOME) { return $Path } + if ($Path.StartsWith($HOME, [StringComparison]::OrdinalIgnoreCase)) { + return '%USERPROFILE%' + $Path.Substring($HOME.Length) + } + return $Path +} + +function usage { + # The closing marker of a here-string has to sit at column 0, so this block is deliberately unindented. + Write-Host @' +Usage: install-tools.ps1 [options] [tool ...] + +Installs the host tools the fleet's repositories expect, from winget, which is the only source +every one of them has. With no tool named, every managed tool is selected. + +Actions, name one, default -Report: + -r, -Report Report installed and available versions, change nothing + -i, -Install Install what is missing, leave an installed tool at its version + -u, -Upgrade Install what is missing and upgrade what is behind + -Reinstall Remove the installed copy, then install it again + -l, -List List the managed tools and their winget package ids + -h, -Help Show this help + +Options: + -n, -DryRun Print the commands instead of running them + -y, -Yes Do not prompt before changing the host + -o, -Optional Include the optional package set, where a tool has one + -Scope Name a scope, either user or machine, for the copy to act on + +Run this without elevation. No scope is passed unless -Scope names one, so winget acts on the copy +it finds and an installer that needs administrator asks for it itself. Naming a scope that +disagrees with the installed copy would add a second copy beside the first rather than replacing +it, so an upgrade refuses that case and names -Reinstall, which removes the old copy first. + +An install, upgrade or reinstall of docker also brings the WSL platform itself up to Docker +Desktop's own floor where it is behind, and stops Docker Desktop first either way, since Docker +Desktop holds the WSL service open and both a platform update and its own WSL integration +otherwise need a restart to recover from an engine bump. wsl --update raises its own administrator +prompt, which this refuses to start unattended where nothing could answer it, rather than hang. + +Examples: + install-tools.ps1 Report on every tool + install-tools.ps1 -Install Install what is missing + install-tools.ps1 -Upgrade -Yes Bring every tool current, no prompt + install-tools.ps1 -Upgrade uv jq Bring two tools current + install-tools.ps1 -Install -Optional dotnet + install-tools.ps1 -Upgrade -DryRun Show what an upgrade would run + install-tools.ps1 -Reinstall jq -Scope machine +'@ +} + +# --- Host --- + +# Read the host identity, and refuse a host this script cannot install for. +# A host that cannot run this script cannot be repaired by it, so each refusal prints the one command that fixes it. +function Test-HostSupported { + if ($PSVersionTable.PSVersion.Major -lt 7) { + die "This script needs PowerShell 7 or later, and this is $($PSVersionTable.PSVersion). Install it with: winget install --id Microsoft.PowerShell --exact --source winget" + } + if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { + die 'winget not found, and this script installs winget packages. Install App Installer from the Microsoft Store, then run this again.' + } + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $script:ELEVATED = ([Security.Principal.WindowsPrincipal]$identity).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +# --- Execution --- + +# Run a command, or print it under -DryRun. +# A read used to decide what to do runs either way, and only a command that changes the host goes through here. +function run { + param([Parameter(Mandatory)][string]$Command, [Parameter(ValueFromRemainingArguments)][string[]]$Arguments) + if ($script:DRY_RUN) { + Write-Host " [dry run] $Command $($Arguments -join ' ')" + return 0 + } + # The command's own output goes to the console rather than down the pipeline. + # A native command writes to this function's output stream, so without this the caller receives every line the command printed with the exit code appended, and a check against 0 then compares against the first line of output. + & $Command @Arguments | Out-Host + return $LASTEXITCODE +} + +function confirm { + param([Parameter(Mandatory)][string]$Question) + if ($script:ASSUME_YES -or $script:DRY_RUN) { return $true } + # Both are checked because a scheduled task reports one and not the other, and either alone misses a case. + if (-not [Environment]::UserInteractive -or [Console]::IsInputRedirected) { + die 'Not a terminal and -Yes was not given, refusing to change the host unattended' + } + return ((Read-Host "$Question [y/N]") -match '^(y|yes)$') +} + +# --- winget --- + +# The version column of every row winget printed for one id. +# The offsets come from the header rather than from a whitespace split, because a package name carries spaces and splitting on them moves the version into the name. +# A row is kept only where the id column holds the id that was asked for, which drops the trailing count line without a list of strings to ignore. +# Every list returning function here returns with a leading comma, which is what stops PowerShell unrolling a one element array into the element. +# Without it a single installed version arrives as a string, and asking a string for its Count is an error under Set-StrictMode rather than the 1 the caller expects. +function Read-WingetTable { + param([string]$Text, [string]$Id) + $rows = @() + $lines = $Text -split "`r?`n" + $header = $lines | Where-Object { $_ -match '^Name\s+Id\s+Version' } | Select-Object -First 1 + if (-not $header) { return , $rows } + $idColumn = $header.IndexOf('Id') + $versionColumn = $header.IndexOf('Version') + foreach ($line in $lines) { + if ($line.Length -le $versionColumn) { continue } + if (-not $line.Substring($idColumn).StartsWith($Id)) { continue } + $rows += ($line.Substring($versionColumn) -split '\s+')[0] + } + return , $rows +} + +# The id and version of every row winget printed for a source search, unfiltered by id. +# Shares the header driven offsets with Read-WingetTable, but keeps the id column instead of assuming the caller already knows it, which is what Resolve-ToolPackage needs from a family search. +function Read-WingetSearchTable { + param([string]$Text, [string]$Prefix) + $rows = @() + $lines = $Text -split "`r?`n" + $header = $lines | Where-Object { $_ -match '^Name\s+Id\s+Version' } | Select-Object -First 1 + if (-not $header) { return , $rows } + $idColumn = $header.IndexOf('Id') + $versionColumn = $header.IndexOf('Version') + foreach ($line in $lines) { + if ($line.Length -le $versionColumn) { continue } + $id = ($line.Substring($idColumn, $versionColumn - $idColumn)).Trim() + if (-not $id.StartsWith($Prefix)) { continue } + $version = ($line.Substring($versionColumn) -split '\s+')[0] + $rows += [PSCustomObject]@{ Id = $id; Version = $version } + } + return , $rows +} + +# The installed versions of one package id, or an empty list where none is installed. +# The exit code decides rather than the output text, since a missing id and an unreadable source both print prose and only the code tells them apart. +# A null answer means the question could not be answered, which is not the same as none installed. +function Get-WingetInstalled { + param([Parameter(Mandatory)][string]$Id, [string]$InScope) + $arguments = @('list', '--id', $Id, '--exact', '--source', 'winget', '--disable-interactivity') + if ($InScope) { $arguments += @('--scope', $InScope) } + $text = (& winget @arguments 2>&1 | Out-String -Width 500) + if ($LASTEXITCODE -eq $script:NOT_FOUND) { return , @() } + if ($LASTEXITCODE -ne 0) { return $null } + return , (Read-WingetTable -Text $text -Id $Id) +} + +# One version for a package winget lists more than once, or nothing where the rows do not describe one product. +# Rows sharing a major version are side by side builds of one product and the newest is the answer, which is what a dotnet SDK line looks like. +# Rows whose majors differ are two products sharing an id, which is what the legacy WSL installer looks like beside WSL itself, and there no single version compares. +function Resolve-InstalledVersion { + param([string[]]$Version) + if (-not $Version -or $Version.Count -eq 0) { return $null } + if ($Version.Count -eq 1) { return $Version[0] } + $majors = @($Version | ForEach-Object { ($_ -split '\.')[0] } | Sort-Object -Unique) + if ($majors.Count -ne 1) { return $null } + + # Compared component by component rather than sorted, because Sort-Object orders a version as text and 10.0.9 then outranks 10.0.10. + # Three side by side dotnet builds hid this, since 110, 204 and 302 are all three digits and sort the same either way. + $newest = $Version[0] + foreach ($candidate in $Version) { + if ((Compare-HostVersion $candidate $newest) -gt 0) { $newest = $candidate } + } + return $newest +} + +# What the source carries now, read without installing anything. +# The show command prints one Version line even for an id the list command answers with several rows, which is what makes it the reader for the target rather than a second list call. +function Get-WingetAvailable { + param([Parameter(Mandatory)][string]$Id) + $text = (& winget show --id $Id --exact --source winget --disable-interactivity 2>&1 | Out-String -Width 500) + if ($LASTEXITCODE -ne 0) { return $null } + if ($text -match '(?m)^Version:\s+(\S+)\s*$') { + if ($Matches[1] -eq 'Unknown') { return $null } + return $Matches[1] + } + return $null +} + +# Which scopes carry a copy, as a sorted list. +# Two probes rather than one reading, because winget reports no scope column and a package installed in both scopes is the case worth catching. +function Get-WingetScope { + param([Parameter(Mandatory)][string]$Id) + $found = @() + foreach ($candidate in 'user', 'machine') { + $rows = Get-WingetInstalled -Id $Id -InScope $candidate + if ($null -ne $rows -and $rows.Count -gt 0) { $found += $candidate } + } + return , $found +} + +# Whether winget wrote the uninstall entry for this package itself, which it does for a portable or an archive package and not for an exe or an msi. +# This is the only positive evidence of provenance available, and its absence proves nothing: winget runs the vendor's own installer for an exe or an msi, so that entry is identical whether winget invoked it or a person did. +# Reported where present and silent where not, rather than being turned into a claim it cannot support. +function Test-WingetOwnedEntry { + param([Parameter(Mandatory)][string]$Id) + $suffix = '_Microsoft.Winget.Source_8wekyb3d8bbwe' + $roots = @( + 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + ) + foreach ($root in $roots) { + if (Test-Path (Join-Path $root ($Id + $suffix))) { return $true } + } + return $false +} + +# The package ids winget refuses to move as part of an upgrade of everything. +# A package lands here because its manifest asks for it, which is the author saying the application updates itself, and the version winget reports is then the one the installer first wrote rather than the one the application runs. +# Read once and cached, since it costs a full upgrade query. +function Get-ExplicitUpgrade { + if ($null -ne $script:EXPLICIT) { return , $script:EXPLICIT } + $script:EXPLICIT = @() + $text = (& winget upgrade --include-unknown --disable-interactivity 2>&1 | Out-String -Width 500) + if ($LASTEXITCODE -ne 0) { return $script:EXPLICIT } + $marker = 'require explicit targeting for upgrade' + $index = $text.IndexOf($marker) + if ($index -lt 0) { return $script:EXPLICIT } + $tail = $text.Substring($index) + $lines = $tail -split "`r?`n" + $header = $lines | Where-Object { $_ -match '^Name\s+Id\s+Version' } | Select-Object -First 1 + if (-not $header) { return $script:EXPLICIT } + $idColumn = $header.IndexOf('Id') + $versionColumn = $header.IndexOf('Version') + foreach ($line in $lines) { + if ($line.Length -le $versionColumn) { continue } + if ($line -match '^Name\s+Id\s+Version') { continue } + $script:EXPLICIT += ($line.Substring($idColumn, $versionColumn - $idColumn)).Trim() + } + return , $script:EXPLICIT +} + +# The package id winget's own catalog says produced the version this tool reports as installed. +# `winget list --id X --exact` answers yes for a package sharing this tool's family even where X itself is not what is installed (#693: a host running OpenJS.NodeJS, the Current channel, reads as OpenJS.NodeJS.LTS because the two share a publisher and a name), but the version it reports comes straight from the installed entry regardless of which id was asked about, so matching that version against every id in the family, read from a plain source search rather than an id scoped one, is what tells the ids apart without trusting the id column winget answered with at all. +# A tool naming no family resolves to its own package unchanged and costs nothing extra, and a version matching none of the family's ids does the same, since a family search that cannot confirm anything is no worse than not asking. +# A version matching more than one id falls back the same way rather than picking the first: two ids sharing a version happens right at a channel handoff, when a major that just left Current has not yet lost its old id from the search results, and picking between them by list order would be a guess wearing the shape of an answer. +function Resolve-ToolPackage { + param([Parameter(Mandatory)][hashtable]$Tool, [string]$Installed) + if (-not $Tool.Family -or -not $Installed) { return $Tool.Package } + $text = (& winget search --query $Tool.Family --source winget --disable-interactivity 2>&1 | Out-String -Width 500) + if ($LASTEXITCODE -ne 0) { return $Tool.Package } + $found = @((Read-WingetSearchTable -Text $text -Prefix $Tool.Family) | Where-Object { $_.Version -eq $Installed }) + if ($found.Count -eq 1) { return $found[0].Id } + return $Tool.Package +} + +# Install, upgrade and remove, named here so the flags appear once in this file. +# No scope is passed unless the caller named one, because winget then acts on the copy it found and naming a different scope adds a copy rather than replacing one. +function Invoke-WingetInstall { + param([Parameter(Mandatory)][string]$Id) + $arguments = @('install', '--id', $Id, '--exact', '--source', 'winget', '--disable-interactivity', + '--accept-source-agreements', '--accept-package-agreements', '--silent') + if ($script:WANT_SCOPE) { $arguments += @('--scope', $script:WANT_SCOPE) } + return (run 'winget' @arguments) +} + +# An upgrade takes the named scope too, on the same rule as an install. +# Reaching here with a scope that disagrees with the installed copy is already refused, so the scope named here is one a copy sits in, and passing it is what says which copy to move where a tool is installed in both. +function Invoke-WingetUpgrade { + param([Parameter(Mandatory)][string]$Id) + $arguments = @('upgrade', '--id', $Id, '--exact', '--source', 'winget', '--disable-interactivity', + '--accept-source-agreements', '--accept-package-agreements', '--silent', '--include-unknown') + if ($script:WANT_SCOPE) { $arguments += @('--scope', $script:WANT_SCOPE) } + return (run 'winget' @arguments) +} + +# The scope to remove from is the one the copy was found in, which the caller passes, rather than the one the caller asked to end up in. +# Removing in the requested scope finds nothing where the copy sits in the other one, so the old copy survives and the install that follows adds a second beside it, which is the state -Reinstall exists to clear. +function Invoke-WingetRemove { + param([Parameter(Mandatory)][string]$Id, [string]$InScope) + $arguments = @('uninstall', '--id', $Id, '--exact', '--disable-interactivity', '--silent') + if ($InScope) { $arguments += @('--scope', $InScope) } + return (run -Command 'winget' -Arguments $arguments) +} + +# --- Tools --- + +# Managed tools, in the order a report lists them. +# Every one is a single winget package, which is what makes this a table where the Linux script needs four functions per tool. +# Probe names the executable that proves the tool is present when winget knows no package for it, and it is py for python because that is the name a correctly set up Windows host carries. +# Family names the id prefix winget's catalog shares across every channel and pinned major a tool ships under, and is empty everywhere but node: node alone ships as more than one id (Current, LTS, and one per pinned major back to 4), any of which is fine on a host as long as its version clears Available, and Resolve-ToolPackage is what finds which one that is. +$TOOLS = @( + @{ Name = 'git'; Package = 'Git.Git'; Probe = 'git'; Optional = @(); Family = '' } + @{ Name = 'gh'; Package = 'GitHub.cli'; Probe = 'gh'; Optional = @(); Family = '' } + @{ Name = 'jq'; Package = 'jqlang.jq'; Probe = 'jq'; Optional = @(); Family = '' } + @{ Name = 'python'; Package = 'Python.Python.3.13'; Probe = 'py'; Optional = @(); Family = '' } + @{ Name = 'uv'; Package = 'astral-sh.uv'; Probe = 'uv'; Optional = @(); Family = '' } + @{ Name = 'docker'; Package = 'Docker.DockerDesktop'; Probe = 'docker'; Optional = @(); Family = '' } + @{ Name = 'node'; Package = 'OpenJS.NodeJS.LTS'; Probe = 'node'; Optional = @(); Family = 'OpenJS.NodeJS' } + @{ Name = 'dotnet'; Package = 'Microsoft.DotNet.SDK.10'; Probe = 'dotnet'; Optional = @('Microsoft.DotNet.SDK.9', 'Microsoft.DotNet.SDK.8'); Family = '' } +) + +function Get-Tool { + param([Parameter(Mandatory)][string]$ToolName) + return ($script:TOOLS | Where-Object { $_.Name -eq $ToolName } | Select-Object -First 1) +} + +# --- Status --- + +# A version as a comparable list of integers, with anything non numeric dropped. +# Mirrors the gate's own comparison so a host reads the same either side of it. +function Get-VersionKey { + param([string]$Version) + $parts = @() + foreach ($part in ($Version -split '[._-]')) { + if ($part -match '^\d+$') { $parts += [int]$part } else { break } + } + if ($parts.Count -eq 0) { return , @(0) } + return , $parts +} + +# Compare two versions, padding the shorter with zeros so more components alone does not read as newer. +function Compare-HostVersion { + param([string]$Left, [string]$Right) + $a = Get-VersionKey $Left + $b = Get-VersionKey $Right + for ($i = 0; $i -lt [Math]::Max($a.Count, $b.Count); $i++) { + $x = if ($i -lt $a.Count) { $a[$i] } else { 0 } + $y = if ($i -lt $b.Count) { $b[$i] } else { 0 } + if ($x -lt $y) { return -1 } + if ($x -gt $y) { return 1 } + } + return 0 +} + +# Everything a report and an apply both need about one tool, read once. +function Get-ToolState { + param([Parameter(Mandatory)][hashtable]$Tool) + $rows = Get-WingetInstalled -Id $Tool.Package + $state = @{ + # The id acted on from here down. + # Reading rows above already answered whether something from this tool's family is installed and at what version, and this may still change below, to the id that version actually belongs to, before anything here is reported or acted on. + Package = $Tool.Package + Installed = $null + Rows = @() + # Whether the installed state was read at all, kept apart from what it said. + # Folding a failed read into an empty list would report a tool whose state is unknown as one that is not installed, and an install would then run against a host nobody measured. + Readable = ($null -ne $rows) + Available = (Get-WingetAvailable -Id $Tool.Package) + Scope = @() + Status = 'unknown' + } + if ($state.Readable) { + $state.Rows = $rows + $state.Installed = Resolve-InstalledVersion -Version $rows + $state.Package = Resolve-ToolPackage -Tool $Tool -Installed $state.Installed + if ($rows.Count -gt 0) { $state.Scope = Get-WingetScope -Id $state.Package } + } + $state.Status = Get-ToolStatus -Tool $Tool -State $state + return $state +} + +# One word for what a tool needs. +# The three Windows meanings sit beside the five the Linux peer carries, and each names a different reason a version comparison would mislead. +function Get-ToolStatus { + param([Parameter(Mandatory)][hashtable]$Tool, [Parameter(Mandatory)][hashtable]$State) + if (-not $State.Readable) { return 'unreadable' } + if ($State.Rows.Count -eq 0) { + # A tool answering on PATH that winget knows no package for is one winget cannot manage at all. + if (Get-Command $Tool.Probe -ErrorAction SilentlyContinue) { return 'unmanaged' } + if (-not $State.Available) { return 'unavailable' } + return 'missing' + } + if (-not $State.Installed) { return 'multiple' } + if (-not $State.Available) { return 'unknown' } + if ((Get-ExplicitUpgrade) -contains $State.Package) { return 'self-updating' } + if ((Compare-HostVersion $State.Installed $State.Available) -ge 0) { return 'current' } + return 'outdated' +} + +# Per tool detail worth a line under the report, rather than a column of its own. +function Add-ToolNote { + param([Parameter(Mandatory)][hashtable]$Tool, [Parameter(Mandatory)][hashtable]$State) + if ($Tool.Name -eq 'python') { + # Written unexpanded, because the expanded form names a real account and the prose gate rejects that. + note 'python' 'python3 resolves to the Microsoft Store alias stub under %LOCALAPPDATA%\Microsoft\WindowsApps, so py -3 is the name this contract uses here' + $resolved = Get-Command python -ErrorAction SilentlyContinue + if ($resolved -and $resolved.Source -notmatch 'Python\d') { + note 'python' "python resolves to $(Hide-Home $resolved.Source), which is not the interpreter winget installed" + } + } + if ($State.Scope.Count -gt 1) { + note $Tool.Name 'installed in both scopes, so one copy shadows the other on PATH, and -Reinstall removes one' + } + if ($State.Rows.Count -gt 0 -and (Test-WingetOwnedEntry -Id $State.Package)) { + note $Tool.Name 'winget wrote this uninstall entry, so winget installed it' + } + if ($State.Status -eq 'multiple') { + note $Tool.Name "winget lists $($State.Rows -join ', ') under one id, and their major versions differ, so no single installed version compares" + } + if ($State.Status -eq 'unmanaged') { + note $Tool.Name 'answers on PATH and winget knows no package for it, so winget cannot upgrade it and -Reinstall does not apply' + } + if ($State.Status -eq 'unreadable') { + note $Tool.Name 'winget did not answer what is installed, so this row reports nothing rather than reporting it as absent' + } + if ($State.Status -eq 'self-updating') { + note $Tool.Name 'updates itself, so the version winget reports is the one it was installed at rather than the one it runs' + } + if ($Tool.Name -eq 'dotnet' -and -not $script:WITH_OPTIONAL) { + note 'dotnet' "optional set not selected: $($Tool.Optional -join ', ')" + } + if ($Tool.Name -eq 'docker') { + $wslProblem = Test-WslReadyForDocker + if ($wslProblem) { note 'docker' $wslProblem } + } +} + +# --- WSL --- + +# Docker Desktop's own documented floor for the WSL platform, per docs.docker.com/desktop/features/wsl. +$DOCKER_WSL_FLOOR = '2.1.5' + +# Every wsl.exe read goes through here, because wsl.exe emits UTF-16 by default and its output then reads as NUL separated characters. +# A wsl.exe call that changes the host instead of reading it goes through Invoke-WslRun below, which applies the same guard to a live streamed run rather than a captured one. +# Mirrored from upgrade-host.ps1 rather than shared with it, on the same rule as the rest of this directory: a script here has to stay independently fetchable. +function Invoke-Wsl { + param([Parameter(ValueFromRemainingArguments)][string[]]$Arguments) + $previous = $env:WSL_UTF8 + try { + $env:WSL_UTF8 = '1' + $text = (& wsl.exe @Arguments 2>&1 | Out-String -Width 500) + if ($text.Contains([char]0)) { $text = $text -replace "`0", '' } + return $text + } finally { + if ($null -eq $previous) { Remove-Item Env:\WSL_UTF8 -ErrorAction SilentlyContinue } + else { $env:WSL_UTF8 = $previous } + } +} + +# The mutation-side counterpart to Invoke-Wsl: runs a wsl.exe command that changes the host through the same run wrapper every other mutating command in this script uses, with the same WSL_UTF8 guard applied around it, so its live streamed output does not read to a person as NUL separated characters either. +function Invoke-WslRun { + param([Parameter(ValueFromRemainingArguments)][string[]]$Arguments) + $previous = $env:WSL_UTF8 + try { + $env:WSL_UTF8 = '1' + return (run 'wsl.exe' @Arguments) + } finally { + if ($null -eq $previous) { Remove-Item Env:\WSL_UTF8 -ErrorAction SilentlyContinue } + else { $env:WSL_UTF8 = $previous } + } +} + +# Just the "WSL version" line, as a value Compare-HostVersion can read. +# Get-WslVersion in upgrade-host.ps1 concatenates WSL, kernel and WSLg into one display string instead, which serves a report rather than a comparison. +function Get-WslPlatformVersion { + if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { return $null } + $text = Invoke-Wsl '--version' + if ($LASTEXITCODE -ne 0) { return $null } + if ($text -match '(?m)^WSL version:\s*(\S+)\s*$') { return $Matches[1] } + return $null +} + +# What stands between this host and installing or upgrading docker, or $null where nothing does. +# A pure report either way, used as-is for -Report's note and as Enter-DockerMaintenance's own pre-check below, which is what actually drives the WSL platform update this used to only name. +# Standing up WSL from nothing with wsl --install stays a person's own step regardless, since that is a different action on a different subject from bringing an existing platform current, the same reasoning that keeps distribution installs in setup-wsl.ps1 rather than folded in here. +function Test-WslReadyForDocker { + if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { + return "wsl.exe was not found, and Docker Desktop needs WSL2. Install it with: wsl --install --no-distribution" + } + $version = Get-WslPlatformVersion + if (-not $version) { + return "the WSL version could not be read, and Docker Desktop needs WSL $($script:DOCKER_WSL_FLOOR) or later. Update it with: host-setup\windows\upgrade-host.ps1 -Wsl" + } + if ((Compare-HostVersion $version $script:DOCKER_WSL_FLOOR) -lt 0) { + return "WSL is at $version, and Docker Desktop needs $($script:DOCKER_WSL_FLOOR) or later. Update it with: host-setup\windows\upgrade-host.ps1 -Wsl" + } + return $null +} + +# --- Docker --- + +# The processes that mean Docker Desktop is up when its own CLI cannot answer, mirrored from upgrade-host.ps1's Get-DockerProcess rather than shared with it, on the same rule as the rest of this directory: a script here has to stay independently fetchable. +$DOCKER_PROCESSES = @('Docker Desktop', 'com.docker.backend', 'com.docker.build') + +# Whether Docker Desktop answers as running right now, preferring its own CLI over a process probe, since the CLI is what agrees with what the tray icon shows and a vmmem process surviving a crash is not the same as the backend actually being up. +# Falls back to the process probe where the CLI itself cannot answer (missing from PATH, or the status call failing), rather than reading either as "not running" outright: that reading is what let Enter-DockerMaintenance run wsl --update against a Docker Desktop this function wrongly reported as already down. +function Test-DockerDesktopRunning { + if (Get-Command docker -ErrorAction SilentlyContinue) { + $text = (& docker desktop status --format json 2>&1 | Out-String) + if ($LASTEXITCODE -eq 0) { + # Parsed rather than matched, the same way setup-wsl.ps1 reads Docker's own settings file, with the regex kept as a fallback for a CLI version that ever answers non-JSON rather than reading that as "not running". + try { + return ((ConvertFrom-Json $text -ErrorAction Stop).Status -eq 'running') + } catch { + return ($text -match '"Status"\s*:\s*"running"') + } + } + } + return [bool](Get-Process -Name $script:DOCKER_PROCESSES -ErrorAction SilentlyContinue) +} + +function Stop-DockerDesktop { + # Test-DockerDesktopRunning can answer true through the process probe alone, with the docker CLI missing or off PATH, and & docker there is a command-not-found PowerShell throws rather than a native exit code this can warn on, so this checks first rather than let that surface as a crash. + if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { + warn 'docker desktop stop needs the docker CLI, which is not on PATH; stop Docker Desktop by hand from its tray icon' + return 1 + } + info 'Stopping Docker Desktop' + $code = run 'docker' @('desktop', 'stop', '--timeout', '90') + if ($code -ne 0) { warn "docker desktop stop exited $code" } + return $code +} + +function Start-DockerDesktop { + if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { + warn 'docker desktop start needs the docker CLI, which is not on PATH; start Docker Desktop by hand and check WSL integration per distro' + return 1 + } + info 'Starting Docker Desktop' + $code = run 'docker' @('desktop', 'start', '--timeout', '180') + if ($code -ne 0) { warn 'docker desktop start did not exit cleanly, start Docker Desktop by hand and check WSL integration per distro' } + return $code +} + +# Everything a docker install, upgrade or reinstall needs from Docker Desktop and WSL before winget touches the package is folded into one window rather than run twice: Docker Desktop holds the WSL service open, so a platform update fails part way while it is running, the same reason upgrade-host.ps1 -Wsl refuses outright. +# Docker Desktop's own per distro WSL integration also goes stale across an engine bump often enough that it has a name on Docker's own tracker, surfacing as "WSL integration with distro '<name>' unexpectedly stopped" the next time anything in that distro touches docker, and the in app "Restart the WSL integration" button on that dialog does not clear it, since it retries the proxy inside the distro that is already running against the same stale state. +# Both are fixed the same way where Docker Desktop is running to begin with: stopped, WSL brought current, every distro shut down with it so each one's integration remounts fresh rather than being patched in place, and Docker Desktop started again once the docker package itself is also settled, by Exit-DockerMaintenance below; a run that finds Docker Desktop already stopped has nothing to restore and none of that runs. +# Boundary note: this script used to be read-only on WSL by design, naming upgrade-host.ps1 -Wsl as a person's own step rather than running it. +# The maintainer moved that boundary once a docker version bump on its own already needed this same stop, then restart, window for its own integration to recover, since a platform update asks for nothing more than that same window with Docker Desktop already stopped inside it. +# Returns whether docker is clear to proceed, and whether this stopped Docker Desktop, which is what Exit-DockerMaintenance needs to know whether it owes a restart. +function Enter-DockerMaintenance { + param([Parameter(Mandatory)][bool]$WasRunning, [string]$WslProblem) + + $result = @{ Proceed = $true; Stopped = $false } + if (-not $WasRunning -and -not $WslProblem) { return $result } + + # Test-WslReadyForDocker's own string carries a remedy clause for a caller that will not fix WSL itself, "Update it with: host-setup\windows\upgrade-host.ps1 -Wsl", which reads as misleading and duplicative embedded in a prompt for a run about to do exactly that. + $wslReason = if ($WslProblem) { $WslProblem -replace '\s*Update it with:.*$', '' } else { $WslProblem } + $question = if ($WslProblem -and $WasRunning) { + "docker needs WSL updated first ($wslReason). Stop Docker Desktop, update WSL, and restart Docker Desktop to continue?" + } elseif ($WslProblem) { + "docker needs WSL updated first ($wslReason). Update WSL to continue?" + } else { + "docker is about to be upgraded or reinstalled, and Docker Desktop's own WSL integration commonly goes stale across a change like that. Stop Docker Desktop first, and restart it after, to avoid that?" + } + if (-not (confirm $question)) { + if ($WslProblem) { $result.Proceed = $false; return $result } + note 'docker' 'WSL integration may report a stale per-distro error until Docker Desktop and WSL are both restarted (docker desktop stop; wsl --shutdown; docker desktop start); declined the automatic cycle' + return $result + } + + # Gated on whether Docker Desktop is still observed running after the attempt, not on Stop-DockerDesktop's own exit code: a non-zero exit that still left Docker Desktop stopped is not a reason to refuse the run and, just as importantly, not a reason to leave Stopped unset, which would tell Exit-DockerMaintenance below there is nothing to restart and strand the host stopped when it started running. + # Skipped under -DryRun, where nothing actually stopped and Docker Desktop is still genuinely running, which would otherwise read every dry run on a live host as this exact failure rather than as a preview. + if ($WasRunning) { + Stop-DockerDesktop | Out-Null + if (-not $script:DRY_RUN -and (Test-DockerDesktopRunning)) { + warn 'docker skipped, Docker Desktop did not stop, and a WSL platform update fails part way while it is running' + $result.Proceed = $false + return $result + } + $result.Stopped = $true + } + if ($WslProblem) { + # Installing through wsl --update's own MSI raises a UAC prompt when this pwsh is not itself elevated, the state the rest of this script deliberately stays in (running elevated trades this one prompt for winget installers elsewhere that fail pre-elevated instead). + # Nothing can answer that prompt without a person at the console, so a run with neither would hang rather than fail, which -Yes is supposed to rule out, not walk into unattended. + if (-not $script:ELEVATED -and (-not [Environment]::UserInteractive -or [Console]::IsInputRedirected)) { + # Built rather than a fixed string, because whether this refusal itself restarts Docker Desktop depends on whether $result.Stopped is set, and a run that found it already stopped restarts nothing for the remedy below to run into. + $restartNote = if ($result.Stopped) { ' This refusal also restarts Docker Desktop, to leave the host as it found it, so it may already be running again by the time this is read.' } else { '' } + warn "wsl --update needs administrator and raises its own prompt, which nothing can answer unattended; it would hang rather than fail, so this refuses to start it. Run interactively once so the prompt has someone to answer, or quit Docker Desktop yourself and run host-setup\windows\upgrade-host.ps1 -Wsl, which refuses on its own while Docker Desktop is running.$restartNote" + if ($result.Stopped) { Start-DockerDesktop | Out-Null; $result.Stopped = $false } + $result.Proceed = $false + return $result + } + info 'Updating the WSL platform' + info 'This restarts every distribution, so anything running inside one is stopped' + $updateCode = Invoke-WslRun '--update' + if ($updateCode -ne 0) { warn "wsl --update exited $updateCode" } + # Skipped under -DryRun on the same rule as the stop check above: wsl --update never ran, so WSL is still genuinely behind, and re-checking it here would read every dry run as this same failure rather than as a preview. + $stillBroken = if ($script:DRY_RUN) { $null } else { Test-WslReadyForDocker } + if ($stillBroken) { + warn "docker skipped, still $stillBroken after the update" + if ($result.Stopped) { Start-DockerDesktop | Out-Null; $result.Stopped = $false } + $result.Proceed = $false + return $result + } + } + return $result +} + +# The other half of Enter-DockerMaintenance, run once the docker package itself is settled: shuts every WSL distro down so each one's integration remounts fresh, then starts Docker Desktop again. +# A no-op where Enter-DockerMaintenance never stopped anything, so a run that changed nothing about Docker Desktop does not restart it for no reason, and a run that was declined does not either. +function Exit-DockerMaintenance { + param([Parameter(Mandatory)][bool]$Stopped) + if (-not $Stopped) { return } + info 'Shutting down WSL' + $shutdownCode = Invoke-WslRun '--shutdown' + if ($shutdownCode -ne 0) { warn "wsl --shutdown exited $shutdownCode" } + Start-DockerDesktop | Out-Null +} + +# --- Actions --- + +function Show-List { + $format = '{0,-10} {1,-24} {2}' + log ($format -f 'TOOL', 'PACKAGE', 'OPTIONAL') + foreach ($tool in $script:SELECTED) { + $record = Get-Tool $tool + $optional = if ($record.Optional.Count -gt 0) { $record.Optional -join ', ' } else { '-' } + log ($format -f $record.Name, $record.Package, $optional) + } +} + +function Show-Report { + $format = '{0,-10} {1,-16} {2,-16} {3,-24} {4,-13} {5}' + log ($format -f 'TOOL', 'INSTALLED', 'AVAILABLE', 'SOURCE', 'SCOPE', 'STATUS') + + foreach ($tool in $script:SELECTED) { + $record = Get-Tool $tool + $state = Get-ToolState -Tool $record + # Every row is printed only where they did not resolve to one version, since a dotnet line carrying three side by side builds resolves cleanly and listing all three would overflow the column for nothing. + $installed = if ($state.Status -eq 'multiple') { $state.Rows -join ',' } elseif ($state.Installed) { $state.Installed } else { '-' } + $available = if ($state.Available) { $state.Available } else { '-' } + $scope = if ($state.Scope.Count -gt 0) { $state.Scope -join '+' } else { '-' } + log ($format -f $record.Name, $installed, $available, $state.Package, $scope, $state.Status) + Add-ToolNote -Tool $record -State $state + } + + if ($script:ELEVATED) { + note 'report' 'this pwsh is elevated, and some installers fail when launched from an elevated process, so an unelevated run is the one to prefer' + } + + if ($script:NOTES.Count -eq 0) { return } + log '' + log 'Notes:' + foreach ($entry in $script:NOTES) { info $entry } +} + +# Install, upgrade or reinstall one tool. +# A tool whose install returns non-zero is collected rather than fatal, so one failure does not strand the rest of the run. +# A refusal is not a failure and does end the run: a declined prompt, or a scope that disagrees with the installed copy, stops everything rather than being collected, because continuing past either would install a copy nobody asked for. +function Invoke-ToolApply { + param([Parameter(Mandatory)][string]$ToolName) + $record = Get-Tool $ToolName + $state = Get-ToolState -Tool $record + # The id fresh work goes to, which is the installed id itself except in exactly one case: an outdated tool resolved to an id outside the tool's own default, which for node means a pinned major that is frozen and can never itself clear Available, so the default is the only id capable of fixing it. + $target = if ($state.Status -eq 'outdated' -and $state.Package -ne $record.Package) { $record.Package } else { $state.Package } + + if ($state.Status -eq 'unmanaged') { + log "${ToolName}: answers on PATH and winget knows no package for it, leaving it alone" + return + } + + # Collected rather than fatal, on the same rule as a failed install, and never installed past. + # Installing against a state nobody could read is how a second copy lands beside a first one that was there all along. + if ($state.Status -eq 'unreadable') { + warn "$ToolName skipped, winget did not answer what is installed and this will not install against an unknown state" + $script:FAILED += $ToolName + return + } + + # Naming a scope the installed copy does not sit in would add a second copy beside it, so the removal is asked for rather than done on the way past. + if ($script:WANT_SCOPE -and $state.Rows.Count -gt 0 -and $state.Scope.Count -gt 0 -and + $state.Scope -notcontains $script:WANT_SCOPE -and $script:MODE -ne 'reinstall') { + die "${ToolName}: installed $($state.Scope -join ' and ') wide at $($state.Installed), and -Scope $($script:WANT_SCOPE) was given. Installing would add a second copy beside it. Remove the existing copy first with: install-tools.ps1 -Reinstall $ToolName -Scope $($script:WANT_SCOPE)" + } + + if ($script:MODE -eq 'reinstall') { + if ($state.Rows.Count -eq 0) { + log "${ToolName}: not installed, so there is nothing to remove" + } else { + $where = if ($state.Scope.Count -gt 0) { " installed $($state.Scope -join ' and ') wide" } else { '' } + $again = if ($target -eq $state.Package) { 'install it again' } else { "install $target instead" } + if (-not (confirm "Remove $($state.Package) at $($state.Rows -join ', ')$where and $($again)?")) { + die 'Declined' + } + # Every copy is removed, each in the scope it was found in, since a tool present in both scopes is exactly the shadowing this action exists to clear. + # An empty scope means winget reported none, and there the removal names none either and lets winget act on what it finds. + $found = if ($state.Scope.Count -gt 0) { $state.Scope } else { @('') } + foreach ($scope in $found) { + if ((Invoke-WingetRemove -Id $state.Package -InScope $scope) -ne 0) { + warn "$ToolName failed to uninstall$(if ($scope) { " the $scope wide copy" })" + $script:FAILED += $ToolName + return + } + } + } + } elseif ($state.Status -eq 'current') { + log "${ToolName}: current at $($state.Installed), leaving it alone" + return + } elseif ($state.Status -eq 'self-updating') { + log "${ToolName}: updates itself, and winget does not move it" + return + } elseif ($state.Status -eq 'multiple') { + log "${ToolName}: winget lists $($state.Rows -join ', ') under one id, so -Reinstall is the action that resolves it" + return + } elseif (($script:MODE -eq 'upgrade' -or $script:MODE -eq 'install') -and $state.Status -eq 'outdated' -and $target -ne $state.Package) { + # Named ahead of the plain install-mode branch below, since -Upgrade would hit this same fixed-release wall, so pointing -Install at -Upgrade here would only trade one dead end for another. + log "${ToolName}: $($state.Package) at $($state.Installed) is a fixed release and cannot advance under that id, -Reinstall $ToolName replaces it with $target" + return + } elseif ($script:MODE -eq 'install' -and $state.Status -eq 'outdated') { + log "${ToolName}: at $($state.Installed), the source carries $($state.Available), -Upgrade moves it" + return + } + + # Reached only once real work is about to happen: every "leave it alone" status above already returned. + # That is deliberate here, not incidental: an already current docker with a stale WSL platform has nothing this run needs to fix, and gating on WSL readiness any earlier would skip a tool that was never going to change anyway. + $dockerMaintenance = $null + if ($ToolName -eq 'docker') { + $wasRunning = Test-DockerDesktopRunning + $wslProblem = Test-WslReadyForDocker + if ($wslProblem -and -not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { + warn "docker skipped, $wslProblem" + $script:FAILED += $ToolName + return + } + $dockerMaintenance = Enter-DockerMaintenance -WasRunning $wasRunning -WslProblem $wslProblem + if (-not $dockerMaintenance.Proceed) { + # $wslProblem is empty on the path where Enter-DockerMaintenance refused over a failed Docker Desktop stop rather than a WSL gap, and that reason was already warned there, but a bare "docker skipped, " with nothing after the comma still reads as a broken log line rather than a completed one. + warn "docker skipped$(if ($wslProblem) { ", $wslProblem" } else { ', Docker Desktop maintenance failed, see the warning above' })" + $script:FAILED += $ToolName + return + } + } + + if ($script:MODE -ne 'reinstall') { + log "${ToolName}: $($state.Status)$(if ($state.Available) { ", the source carries $($state.Available)" })" + } + + $packages = @($target) + if ($script:WITH_OPTIONAL) { $packages += $record.Optional } + + foreach ($package in $packages) { + $code = if ($state.Rows.Count -gt 0 -and $script:MODE -eq 'upgrade' -and $package -eq $target) { + Invoke-WingetUpgrade -Id $package + } else { + Invoke-WingetInstall -Id $package + } + if ($code -ne 0) { + warn "$ToolName failed on $package, winget exited $code" + # Windows will not replace a file that is open, and winget reports that as an access denial naming the file rather than whatever holds it. + # The holder is usually the tool itself, left running by an editor or a language server, so the process is named here and the reader is spared guessing at a permission problem that is not one. + $running = @(Get-Process -Name $record.Probe -ErrorAction SilentlyContinue) + if ($running.Count -gt 0) { + info "$($record.Probe) is running as process $($running.Id -join ', '), and Windows cannot replace a running executable" + info 'Close whatever is running it, then run this again' + } + $script:FAILED += $ToolName + # Owed regardless of how this returns: Enter-DockerMaintenance stopped Docker Desktop for this attempt, and a failed install is not a reason to leave it down. + if ($dockerMaintenance) { Exit-DockerMaintenance -Stopped $dockerMaintenance.Stopped } + return + } + } + + $now = Resolve-InstalledVersion -Version (Get-WingetInstalled -Id $target) + if ($now -ne $state.Installed) { + $before = if ($state.Installed) { $state.Installed } else { '-' } + $after = if ($now) { $now } else { '-' } + $script:CHANGED += "$ToolName $before -> $after" + } + if ($dockerMaintenance) { Exit-DockerMaintenance -Stopped $dockerMaintenance.Stopped } +} + +function Invoke-Apply { + log "Selected: $($script:SELECTED -join ' ')" + if ($script:ELEVATED) { + warn 'This pwsh is elevated, and some installers fail when launched from an elevated process. An unelevated run lets each installer ask for administrator only where it needs it.' + } + if (-not (confirm "$($script:MODE) these tools?")) { die 'Declined' } + + foreach ($tool in $script:SELECTED) { + step $tool + Invoke-ToolApply -ToolName $tool + } + + log '' + if ($script:CHANGED.Count -gt 0) { + log 'Changed:' + foreach ($entry in $script:CHANGED) { info $entry } + } else { + log 'Nothing changed' + } + + if ($script:FAILED.Count -gt 0) { + warn "Failed: $($script:FAILED -join ' ')" + return 1 + } + return 0 +} + +# --- Entry --- + +# PowerShell records which switches were given and not the order they came in, so two actions is a refusal rather than the last one winning. +# Refusing is also the better answer: an action silently discarded is one the caller believes ran. +function Resolve-Mode { + $given = @($script:ACTIONS.Keys | Where-Object { $script:ACTIONS[$_] }) + if ($given.Count -gt 1) { die "More than one action given ($($given -join ', ')), name one" } + if ($given.Count -eq 0) { return 'report' } + return $given[0] +} + +function Resolve-Selection { + $names = @($script:TOOLS | ForEach-Object { $_.Name }) + if ($script:WANT_TOOLS.Count -eq 0) { return , $names } + foreach ($candidate in $script:WANT_TOOLS) { + if ($names -notcontains $candidate) { + die "Unknown tool `"$candidate`", -List names the managed tools" + } + } + # Sorted into registry order rather than the order they were typed, so a run reads the same however it was asked for. + return , @($names | Where-Object { $script:WANT_TOOLS -contains $_ }) +} + +function main { + if ($script:WANT_HELP) { usage; exit 0 } + $script:MODE = Resolve-Mode + Test-HostSupported + $script:SELECTED = Resolve-Selection + + switch ($script:MODE) { + 'list' { Show-List; exit 0 } + 'report' { Show-Report; exit 0 } + default { exit (Invoke-Apply) } + } +} + +main diff --git a/host-setup/windows/setup-github.ps1 b/host-setup/windows/setup-github.ps1 index dde3b53d..efa10214 100644 --- a/host-setup/windows/setup-github.ps1 +++ b/host-setup/windows/setup-github.ps1 @@ -1,612 +1,612 @@ -# Sets up git and GitHub on a Windows host: the SSH key, the git configuration, and commit signing. -# Every step is idempotent, so a re-run repairs a half configured host rather than duplicating what is already there. -# -# Two steps cannot be automated, because they happen in a browser: registering the public key as an authentication key, and registering the same key as a signing key. -# Both are gates rather than suggestions. -# The script stops at each, prints the key to paste and where to paste it, and checks afterwards that the registration took, by reading the keys GitHub publishes for the account. -# -# The path settings are written in the tilde form the Linux peer writes, because git expands it on Windows too, and a home shared with a WSL distribution then carries one value rather than two that disagree. - -[CmdletBinding()] -param( - [Alias('s')][switch]$Status, - [Alias('c')][switch]$Configure, - [Alias('n')][switch]$DryRun, - [Alias('y')][switch]$Yes, - [string]$Name, - [string]$Email, - [string]$SharedCheckout, - [Alias('h')][switch]$Help -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' -# A non-zero exit from git or ssh is an answer here rather than a failure. -# Setting this keeps a profile that turned it on from turning every read into a terminating error. -$PSNativeCommandUseErrorActionPreference = $false - -$KEY = Join-Path $HOME '.ssh\id_ed25519' -$ALLOWED_SIGNERS = Join-Path $HOME '.config\git\allowed_signers' - -# Git expands a leading tilde in a path setting on Windows as well, and the hosts already configured by hand hold the tilde form, so writing that form leaves an already configured host untouched. -# File operations use the expanded paths above, since only git expands the tilde. -$KEY_SETTING = '~/.ssh/id_ed25519.pub' -$ALLOWED_SIGNERS_SETTING = '~/.config/git/allowed_signers' -$KEY_SETTINGS_URL = 'https://github.com/settings/ssh/new' - -# The identity the maintainer's commits carry, used only where the host names none of its own. -$DEFAULT_NAME = 'Pieter Viljoen' -$DEFAULT_EMAIL = 'ptr727@users.noreply.github.com' - -# Every parameter is read into a variable here rather than from inside a function. -# A script parameter reached only from a nested scope reads as declared and never used, which is a finding on each one and hides a parameter that genuinely is unused. -$ACTIONS = [ordered]@{ - status = [bool]$Status - configure = [bool]$Configure -} -$WANT_HELP = [bool]$Help - -$MODE = 'status' -$DRY_RUN = [bool]$DryRun -$ASSUME_YES = [bool]$Yes -$WANT_NAME = $Name -$WANT_EMAIL = $Email -$SHARED = $SharedCheckout -$GITHUB_USER = '' -$MANAGED_KEY_AUTHENTICATES = $false - -# --- Output --- - -function log { param([string]$Message = '') Write-Host $Message } -function info { param([string]$Message) Write-Host " $Message" } -function step { param([string]$Message) Write-Host "`n==> $Message" } -function warn { param([string]$Message) [Console]::Error.WriteLine("WARNING: $Message") } -function die { param([string]$Message) [Console]::Error.WriteLine("ERROR: $Message"); exit 1 } - -function ok { param([string]$Message) Write-Host " [ ok ] $Message" } -function missing { param([string]$Message) Write-Host " [ ] $Message" } - -function usage { - # The closing marker of a here-string has to sit at column 0, so this block is deliberately unindented. - Write-Host @' -Usage: setup-github.ps1 [options] - -Sets up git and GitHub on this host: the SSH key, the git configuration, and commit signing. - -Actions, name one, default -Status: - -s, -Status Report what is set up and what is not, change nothing - -c, -Configure Create the key, apply the configuration, and check both registrations - -h, -Help Show this help - -Options: - -n, -DryRun Print the commands instead of running them - -y, -Yes Do not prompt before changing the host - -Name Name for git commits - -Email Email for git commits - -SharedCheckout PATH - Configure a checkout that several accounts share, at PATH. This turns off git's - ownership check for that path, so it is named rather than assumed: a host one - account uses needs it for nothing. "*" applies it to every path on the host, - which is the broadest form and is reported as such. - -The identity comes from -Name and -Email, or from what this host already carries, or from the -default the maintainer commits under, in that order. A host configured for somebody else keeps its -own identity rather than being quietly rewritten. - -Registering the key is done in a browser and cannot be automated, so -Configure stops at each -registration and prints what to paste. The same key is registered twice, once as an authentication -key and once as a signing key: authentication reaches private repositories, signing is what marks a -commit verified. Each is checked against the keys GitHub publishes for the account. - -Two host settings are reported and never written, because both need administrator and rewriting a -working host is worse than naming what to change: the ssh-agent service, and the key file's ACL. - -Examples: - setup-github.ps1 Report, change nothing - setup-github.ps1 -Configure Set the host up - setup-github.ps1 -Configure -DryRun Show what it would do - setup-github.ps1 -Configure -Email you@users.noreply.github.com -'@ -} - -# --- Execution --- - -# Run a command, or print it under -DryRun. -# A read used to decide what to do runs either way, and only a command that changes the host goes through here. -function run { - param([Parameter(Mandatory)][string]$Command, [Parameter(ValueFromRemainingArguments)][string[]]$Arguments) - if ($script:DRY_RUN) { - Write-Host " [dry run] $Command $($Arguments -join ' ')" - return 0 - } - # The command's own output goes to the console rather than down the pipeline. - # A native command writes to this function's output stream, so without this the caller receives every line the command printed with the exit code appended, and a check against 0 then compares against the first line of output. - & $Command @Arguments | Out-Host - return $LASTEXITCODE -} - -function confirm { - param([Parameter(Mandatory)][string]$Question) - if ($script:ASSUME_YES -or $script:DRY_RUN) { return $true } - # Both are checked because a scheduled task reports one and not the other, and either alone misses a case. - if (-not [Environment]::UserInteractive -or [Console]::IsInputRedirected) { - die 'Not a terminal and -Yes was not given, refusing to change the host unattended' - } - return ((Read-Host "$Question [y/N]") -match '^(y|yes)$') -} - -# --- Prerequisites --- - -# Both git and ssh ship with Git for Windows, and neither is installed from here. -# Installing them belongs to install-tools.ps1, so a gap is named with the command that closes it rather than closed twice in two scripts. -# Every executable this script calls is named, rather than the three it is most obviously about. -# One left out fails partway through with whatever that command says when it is missing, in place of the one message here that names the remedy. -function Test-Prerequisite { - $absent = @() - foreach ($tool in 'git', 'ssh', 'ssh-keygen', 'ssh-keyscan', 'ssh-add') { - if (-not (Get-Command $tool -ErrorAction SilentlyContinue)) { $absent += $tool } - } - if ($absent.Count -eq 0) { return } - die "Not found on this host: $($absent -join ', '). All of these ship with Git for Windows, which install-tools.ps1 -Install git installs." -} - -# --- Key --- - -function Get-KeyBody { - if (-not (Test-Path "$script:KEY.pub")) { return $null } - $text = (Get-Content "$script:KEY.pub" -Raw).Trim() - if (-not $text) { return $null } - return $text -} - -function New-KeyIfAbsent { - step 'Checking the SSH key' - if (Test-Path $script:KEY) { - info "Already at $($script:KEY)" - return - } - $directory = Split-Path -Parent $script:KEY - if (-not (Test-Path $directory)) { - if ($script:DRY_RUN) { info "[dry run] create $directory" } else { New-Item -ItemType Directory -Path $directory -Force | Out-Null } - } - info "Creating $($script:KEY)" - # An empty passphrase is not chosen here, so ssh-keygen asks, which is the one question only the operator can answer. - $code = run -Command 'ssh-keygen' -Arguments '-t', 'ed25519', '-C', $script:WANT_EMAIL, '-f', $script:KEY - if ($code -ne 0) { die "ssh-keygen exited $code" } -} - -# The ACL is reported and never rewritten. -# Creating the key with ssh-keygen already sets it correctly on Windows, and rewriting an ACL is hard to undo and hard to preview under -DryRun, so a wrong one is named with the command that repairs it. -function Test-KeyAcl { - if (-not (Test-Path $script:KEY)) { return $null } - $acl = Get-Acl $script:KEY - $others = @($acl.Access | Where-Object { $_.IdentityReference.Value -ne $acl.Owner -and $_.IdentityReference.Value -notmatch 'NT AUTHORITY\\SYSTEM|BUILTIN\\Administrators' }) - return ($others.Count -eq 0) -} - -function Add-KnownHost { - step 'Checking that github.com is a known host' - $known = Join-Path $HOME '.ssh\known_hosts' - if ((& ssh-keygen -F github.com 2>&1 | Out-String) -match 'found') { - info 'Already known' - return - } - info 'Adding the github.com host keys' - if ($script:DRY_RUN) { - info "[dry run] check the github.com host key against the fingerprints GitHub publishes, then record it in $known" - return - } - - # Comment lines carry the banner rather than a key, and ssh-keygen refuses a file holding one. - $scanned = @(& ssh-keyscan github.com 2> $null | Where-Object { $_ -and $_ -notmatch '^\s*#' }) - if ($scanned.Count -eq 0) { - die 'ssh-keyscan returned no host key for github.com. The OpenSSH under %SystemRoot%\System32 is older than the key exchange github.com offers and fails with "unsupported KEX method", where the copy shipped with Git for Windows under %ProgramFiles%\Git\usr\bin succeeds. Put that one first on PATH and run this again.' - } - - # What was offered is checked against what GitHub publishes before it is recorded, matching the Linux peer. - # Recording whatever answered would persist a substituted key on the one run where nothing yet pins the real one, and every later connection would then verify against it. - $temp = Join-Path ([IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString() + '.pub') - try { - [IO.File]::WriteAllText($temp, ($scanned -join "`n") + "`n") - $offered = @(& ssh-keygen -lf $temp 2> $null | - ForEach-Object { ($_ -split '\s+')[1] -replace '^SHA256:', '' } | - Where-Object { $_ } | Sort-Object -Unique) - } finally { - Remove-Item $temp -Force -ErrorAction SilentlyContinue - } - if ($offered.Count -eq 0) { die 'The host key github.com offered could not be fingerprinted, so it is not being recorded' } - - $published = @() - try { - $meta = Invoke-RestMethod -Uri 'https://api.github.com/meta' -TimeoutSec 10 - $published = @($meta.ssh_key_fingerprints.PSObject.Properties | - Where-Object { $_.Name -like 'SHA256*' } | ForEach-Object { $_.Value }) - } catch { - $published = @() - } - if ($published.Count -eq 0) { - die 'Cannot read the host key fingerprints GitHub publishes, so the key it offered cannot be checked. Compare it by hand against https://docs.github.com/authentication/keeping-your-account-secure/githubs-ssh-key-fingerprints and record it with ssh-keyscan.' - } - - foreach ($fingerprint in $offered) { - if ($published -notcontains $fingerprint) { - die "github.com offered a host key GitHub does not publish (SHA256:$fingerprint), so it is not being recorded" - } - } - info 'The offered host key matches what GitHub publishes' - - $directory = Split-Path -Parent $known - if (-not (Test-Path $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } - Add-Content -Path $known -Value $scanned -} - -# --- GitHub, read only --- - -# Every probe refuses to write a host key, so asking whether authentication works cannot enroll github.com behind the reader's back and a status run stays a read-only action. -# A status run also refuses to prompt, since it may be run unattended and a passphrase prompt would hang it. -function Get-SshGreeting { - param([switch]$ManagedKeyOnly) - $options = @('-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=10') - if ($script:MODE -eq 'status') { $options += @('-o', 'BatchMode=yes') } - if ($ManagedKeyOnly) { - if (-not (Test-Path $script:KEY)) { return '' } - # The ssh client offers the default identity files and an agent's keys besides whatever is named, so a host carrying another account's key authenticates as that account. - # An empty config file is what isolates the managed key, since the default identity files count as configured identities on their own. - $options += @('-F', 'NUL', '-o', 'IdentitiesOnly=yes', '-i', $script:KEY) - } - return (& ssh @options -T 'git@github.com' 2>&1 | Out-String) -} - -function Get-GreetingUser { - param([string]$Greeting) - if ($Greeting -match '(?m)^Hi ([^!]+)!') { return $Matches[1] } - return '' -} - -# Prefer the account the managed key belongs to, since that is the one the registration checks are about, and say so when the host answers as somebody else. -function Resolve-GitHubUser { - $general = Get-GreetingUser (Get-SshGreeting) - $managed = Get-GreetingUser (Get-SshGreeting -ManagedKeyOnly) - - $script:MANAGED_KEY_AUTHENTICATES = [bool]$managed - if ($managed) { - $script:GITHUB_USER = $managed - if ($general -and $general -ne $managed) { - warn "This host authenticates as $general with another key, while the managed key belongs to $managed" - } - return - } - $script:GITHUB_USER = $general -} - -# GitHub publishes both key lists for an account, so a registration can be checked from the host without a token and without the browser that made it. -# Each returns registered, missing, or unknown, and the third is not the second: a momentary failure to reach GitHub reported as "not registered" sends the reader to register a key that is already there. -# PowerShell separates those two by itself, since an account with no signing key returns an empty list where an unreachable GitHub throws. -function Test-KeyRegistered { - param([ValidateSet('auth', 'signing')][string]$Kind) - $body = Get-KeyBody - if (-not $body -or -not $script:GITHUB_USER) { return 'unknown' } - # The comment field is not part of what GitHub publishes, so only the type and the key itself are compared. - $wanted = (($body -split '\s+') | Select-Object -First 2) -join ' ' - try { - if ($Kind -eq 'auth') { - $keys = (Invoke-RestMethod -Uri "https://github.com/$($script:GITHUB_USER).keys" -TimeoutSec 10) -split "`n" - } else { - $keys = @(Invoke-RestMethod -Uri "https://api.github.com/users/$($script:GITHUB_USER)/ssh_signing_keys" -TimeoutSec 10 | ForEach-Object { $_.key }) - } - } catch { - return 'unknown' - } - foreach ($key in $keys) { - if ($key.Trim() -eq $wanted) { return 'registered' } - } - return 'missing' -} - -# A registration is a browser step, so this prints what to paste and where, then stops. -function Show-RegistrationNeeded { - param([string]$Kind, [string]$Type) - log '' - log "The key is not registered for $Kind. This step happens in a browser:" - info "1. Open $($script:KEY_SETTINGS_URL)" - info "2. Set the key type to `"$Type key`"" - info "3. Paste the key below, and give it this host's name" - log '' - # A dry run reports what it would create rather than creating it, so the key this block exists to print may not be there. - $body = Get-KeyBody - if ($body) { log $body } else { info "No key at $($script:KEY).pub yet, so there is nothing to paste. A run that is not a dry run creates it." } - log '' -} - -# --- git configuration --- - -function Get-GitConfig { - param([string]$Key) - $value = (& git config --global --get $Key 2>$null | Out-String).Trim() - return $value -} - -# Set a value only where it differs, so a re-run is silent rather than rewriting the same file. -function Set-GitConfig { - param([string]$Key, [string]$Value) - if ((Get-GitConfig $Key) -eq $Value) { return $false } - run -Command 'git' -Arguments 'config', '--global', $Key, $Value | Out-Null - return $true -} - -# The safe.directory setting is multi valued, so setting it again appends a duplicate rather than replacing it. -function Add-GitConfigOnce { - param([string]$Key, [string]$Value) - $current = @(& git config --global --get-all $Key 2>$null) - if ($current -contains $Value) { return $false } - run -Command 'git' -Arguments 'config', '--global', '--add', $Key, $Value | Out-Null - return $true -} - -# The flag wins, then whatever the host already carries, then the default. -# Reading the host first is what keeps a machine configured for somebody else from being rewritten by a run meant to be safe to repeat. -function Resolve-Identity { - if (-not $script:WANT_NAME) { - $existing = Get-GitConfig 'user.name' - $script:WANT_NAME = if ($existing) { $existing } else { $script:DEFAULT_NAME } - } - if (-not $script:WANT_EMAIL) { - $existing = Get-GitConfig 'user.email' - $script:WANT_EMAIL = if ($existing) { $existing } else { $script:DEFAULT_EMAIL } - } -} - -function Set-GitIdentity { - step 'Configuring git' - info "Identity: $($script:WANT_NAME) <$($script:WANT_EMAIL)>" - - $changed = 0 - if (Set-GitConfig 'user.name' $script:WANT_NAME) { $changed++ } - if (Set-GitConfig 'user.email' $script:WANT_EMAIL) { $changed++ } - # Git Credential Manager ships with Git for Windows and is what gh expects there, where the Linux peer writes a cache helper because no manager exists. - # Written only where nothing is set, so a host already carrying a helper keeps it. - if (-not (Get-GitConfig 'credential.helper')) { - if (Set-GitConfig 'credential.helper' 'manager') { $changed++ } - } - - if ($changed -eq 0) { info 'Already configured' } else { info "$changed setting(s) written" } -} - -# A checkout several accounts share needs two settings that are relaxations rather than defaults, so they are applied only for a path the caller names. -function Set-SharedCheckout { - if (-not $script:SHARED) { return } - step "Configuring the shared checkout at $($script:SHARED)" - - $changed = 0 - if (Set-GitConfig 'core.sharedRepository' 'group') { $changed++ } - if (Add-GitConfigOnce 'safe.directory' $script:SHARED) { $changed++ } - - if ($script:SHARED -eq '*') { - warn 'safe.directory is set to every path on this host, which turns the ownership check off everywhere' - } elseif (-not (Test-Path $script:SHARED)) { - info "$($script:SHARED) does not exist yet, and the setting waits for it" - } - - if ($changed -eq 0) { info 'Already configured' } else { info "$changed setting(s) written" } -} - -function Set-Signing { - step 'Configuring commit signing' - - $changed = 0 - if (Set-GitConfig 'gpg.format' 'ssh') { $changed++ } - if (Set-GitConfig 'user.signingkey' $script:KEY_SETTING) { $changed++ } - if (Set-GitConfig 'commit.gpgsign' 'true') { $changed++ } - if (Set-GitConfig 'tag.gpgsign' 'true') { $changed++ } - if (Set-GitConfig 'gpg.ssh.allowedSignersFile' $script:ALLOWED_SIGNERS_SETTING) { $changed++ } - - # The allowed signers file is what verifies a signature locally, and it is appended to rather than rewritten, since it can carry other identities. - $body = Get-KeyBody - if ($body) { - $entry = "$($script:WANT_EMAIL) namespaces=`"git`" $body" - $existing = if (Test-Path $script:ALLOWED_SIGNERS) { @(Get-Content $script:ALLOWED_SIGNERS) } else { @() } - if ($existing -notcontains $entry) { - if ($script:DRY_RUN) { - info "[dry run] append this host's key to $($script:ALLOWED_SIGNERS)" - } else { - $directory = Split-Path -Parent $script:ALLOWED_SIGNERS - if (-not (Test-Path $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } - Add-Content -Path $script:ALLOWED_SIGNERS -Value $entry - } - $changed++ - } - } - - if ($changed -eq 0) { info 'Already configured' } else { info "$changed setting(s) written" } -} - -# Sign a commit in a throwaway repository and verify it. -# This proves the configuration end to end, which reading the settings back cannot: a wrong allowed signers entry reads as correct and fails only when a signature is checked. -function Test-Signing { - $repository = Join-Path ([IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString()) - try { - New-Item -ItemType Directory -Path $repository -Force | Out-Null - & git init -q $repository 2>&1 | Out-Null - if ($LASTEXITCODE -ne 0) { return $false } - & git -C $repository commit -q --allow-empty -m 'signing check' 2>&1 | Out-Null - if ($LASTEXITCODE -ne 0) { return $false } - & git -C $repository verify-commit HEAD 2>&1 | Out-Null - return ($LASTEXITCODE -eq 0) - } catch { - return $false - } finally { - Remove-Item $repository -Recurse -Force -ErrorAction SilentlyContinue - } -} - -# --- Agent --- - -# On Windows the agent is a service rather than a socket, and both starting it and setting it to start automatically need administrator. -# Reported with the command that fixes it rather than run, on the same rule as the key ACL: this script does not elevate, and naming the change is better than half applying it. -function Show-AgentStatus { - log '' - log 'SSH agent' - $service = Get-Service ssh-agent -ErrorAction SilentlyContinue - if (-not $service) { - missing 'the ssh-agent service exists, and OpenSSH is installed as a Windows optional feature' - return - } - if ($service.Status -eq 'Running') { - ok 'the ssh-agent service is running' - } else { - missing "the ssh-agent service is running, it is $($service.Status), start it with: Set-Service ssh-agent -StartupType Automatic; Start-Service ssh-agent" - return - } - - $body = Get-KeyBody - $loaded = (& ssh-add -L 2>&1 | Out-String) - if ($body -and $loaded -match [regex]::Escape((($body -split '\s+') | Select-Object -First 2) -join ' ')) { - ok 'the managed key is loaded in the agent' - } else { - missing "the managed key is loaded in the agent, add it with: ssh-add $($script:KEY)" - } -} - -# --- Actions --- - -function Show-Status { - log 'Host identity' - foreach ($tool in 'git', 'ssh') { - if (Get-Command $tool -ErrorAction SilentlyContinue) { ok "$tool installed" } else { missing "$tool installed, which install-tools.ps1 -Install git provides" } - } - if (Test-Path $script:KEY) { ok "SSH key at $($script:KEY)" } else { missing "SSH key at $($script:KEY)" } - - $acl = Test-KeyAcl - if ($null -eq $acl) { missing 'the key file ACL, unknown until the key exists' } - elseif ($acl) { ok 'the key file is readable only by its owner' } - else { missing "the key file is readable only by its owner, repair it with: icacls `"$($script:KEY)`" /inheritance:r /grant:r `"`$env:USERNAME:F`"" } - - $known = (& ssh-keygen -F github.com 2>&1 | Out-String) -match 'found' - Resolve-GitHubUser - if ($script:GITHUB_USER) { ok "SSH authentication to GitHub, as $($script:GITHUB_USER)" } - elseif (-not $known) { missing 'SSH authentication to GitHub, unchecked because github.com is not in known_hosts, which -Configure adds' } - else { missing 'SSH authentication to GitHub' } - - if ($script:MANAGED_KEY_AUTHENTICATES) { ok 'The managed key is the one that authenticates' } - elseif ($script:GITHUB_USER) { missing 'The managed key authenticates, so the account above answered with another key or through this host ssh config' } - else { missing 'The managed key authenticates' } - - log '' - log 'Registration, as GitHub publishes it' - # Which account to ask about comes from authentication, so with none there is no question to ask, and that reads differently from having asked and failed. - if (-not $script:GITHUB_USER -or -not (Get-KeyBody)) { - missing 'Authentication key registered, unknown until authentication works' - missing 'Signing key registered, unknown until authentication works' - } else { - foreach ($pair in @(@('auth', 'Authentication'), @('signing', 'Signing'))) { - switch (Test-KeyRegistered -Kind $pair[0]) { - 'registered' { ok "$($pair[1]) key registered" } - 'unknown' { missing "$($pair[1]) key registered, GitHub could not be reached to check" } - default { missing "$($pair[1]) key registered" } - } - } - } - - log '' - log 'git configuration' - foreach ($key in 'user.name', 'user.email', 'credential.helper', 'gpg.format', - 'user.signingkey', 'commit.gpgsign', 'tag.gpgsign', 'gpg.ssh.allowedSignersFile') { - $value = Get-GitConfig $key - if ($value) { ok "$key = $value" } else { missing $key } - } - - # The shared checkout settings are reported apart, because absent is the right state for a host one account uses and listing them as missing would read as two gaps to close. - $shared = @(& git config --global --get-all safe.directory 2>$null) - if ($shared.Count -gt 0) { - $unique = @($shared | Sort-Object -Unique) - ok "safe.directory = $($unique -join ' ')" - if ($shared.Count -gt $unique.Count) { - info "$($shared.Count) entries for $($unique.Count) path(s), so this setting was added more than once" - } - } else { - info '[ ] safe.directory not set, which -SharedCheckout <path> sets where a checkout is shared' - } - - Show-AgentStatus - - log '' - log 'GitHub CLI' - if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { - missing 'gh installed, which install-tools.ps1 -Install gh provides' - } else { - $auth = (& gh auth status 2>&1 | Out-String) - if ($auth -match 'Logged in to \S+ account (\S+)') { ok "authenticated as $($Matches[1])" } else { missing 'authenticated, log in with: gh auth login --hostname github.com --git-protocol ssh' } - # Reported and never set, matching the Linux peer, which touches gh nowhere. - # An https protocol makes a checkout made through gh authenticate by token where every other checkout on the host authenticates by key. - if ($auth -match 'Git operations protocol: (\S+)') { - if ($Matches[1] -eq 'ssh') { ok 'git protocol is ssh' } - else { missing "git protocol is ssh, it is $($Matches[1]), set it with: gh config set git_protocol ssh" } - } - } - - log '' - log 'Signing' - if (Test-Signing) { ok 'A commit signs and verifies on this host' } else { missing 'A commit signs and verifies on this host' } -} - -function Invoke-Configure { - Test-Prerequisite - Resolve-Identity - New-KeyIfAbsent - Add-KnownHost - - # The local configuration comes first, because none of it needs GitHub. - # A host that cannot authenticate yet, which is every host between creating its key and registering it, still ends this run with its identity, its signing configuration, and a commit that verifies locally. - Set-GitIdentity - Set-SharedCheckout - Set-Signing - - step 'Signing a commit to check the configuration' - if ($script:DRY_RUN) { - info '[dry run] sign and verify a commit in a throwaway repository' - } elseif (Test-Signing) { - info 'A commit signs and verifies' - } else { - die "A commit did not verify. Check $($script:ALLOWED_SIGNERS) holds this host key against $($script:WANT_EMAIL)." - } - - step 'Checking the key registrations at GitHub' - Resolve-GitHubUser - if (-not $script:GITHUB_USER) { - Show-RegistrationNeeded -Kind 'authentication' -Type 'Authentication' - warn 'Authentication does not work yet, so nothing that reaches GitHub will work until the key above is registered' - return - } - info "Authenticated as $($script:GITHUB_USER)" - - if ((Test-KeyRegistered -Kind 'signing') -eq 'missing') { - Show-RegistrationNeeded -Kind 'signing' -Type 'Signing' - warn 'Commits sign and verify locally, and show as unverified on GitHub until the key above is registered as a signing key' - } else { - info 'The signing key is registered' - } - - Show-AgentStatus - step 'Done' -} - -# --- Entry --- - -# PowerShell records which switches were given and not the order they came in, so two actions is a refusal rather than the last one winning. -# Refusing is also the better answer: an action silently discarded is one the caller believes ran. -function Resolve-Mode { - $given = @($script:ACTIONS.Keys | Where-Object { $script:ACTIONS[$_] }) - if ($given.Count -gt 1) { die "More than one action given ($($given -join ', ')), name one" } - if ($given.Count -eq 0) { return 'status' } - return $given[0] -} - -function main { - if ($script:WANT_HELP) { usage; exit 0 } - $script:MODE = Resolve-Mode - Test-Prerequisite - - if ($script:MODE -eq 'status') { Show-Status } else { Invoke-Configure } -} - -main +# Sets up git and GitHub on a Windows host: the SSH key, the git configuration, and commit signing. +# Every step is idempotent, so a re-run repairs a half configured host rather than duplicating what is already there. +# +# Two steps cannot be automated, because they happen in a browser: registering the public key as an authentication key, and registering the same key as a signing key. +# Both are gates rather than suggestions. +# The script stops at each, prints the key to paste and where to paste it, and checks afterwards that the registration took, by reading the keys GitHub publishes for the account. +# +# The path settings are written in the tilde form the Linux peer writes, because git expands it on Windows too, and a home shared with a WSL distribution then carries one value rather than two that disagree. + +[CmdletBinding()] +param( + [Alias('s')][switch]$Status, + [Alias('c')][switch]$Configure, + [Alias('n')][switch]$DryRun, + [Alias('y')][switch]$Yes, + [string]$Name, + [string]$Email, + [string]$SharedCheckout, + [Alias('h')][switch]$Help +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +# A non-zero exit from git or ssh is an answer here rather than a failure. +# Setting this keeps a profile that turned it on from turning every read into a terminating error. +$PSNativeCommandUseErrorActionPreference = $false + +$KEY = Join-Path $HOME '.ssh\id_ed25519' +$ALLOWED_SIGNERS = Join-Path $HOME '.config\git\allowed_signers' + +# Git expands a leading tilde in a path setting on Windows as well, and the hosts already configured by hand hold the tilde form, so writing that form leaves an already configured host untouched. +# File operations use the expanded paths above, since only git expands the tilde. +$KEY_SETTING = '~/.ssh/id_ed25519.pub' +$ALLOWED_SIGNERS_SETTING = '~/.config/git/allowed_signers' +$KEY_SETTINGS_URL = 'https://github.com/settings/ssh/new' + +# The identity the maintainer's commits carry, used only where the host names none of its own. +$DEFAULT_NAME = 'Pieter Viljoen' +$DEFAULT_EMAIL = 'ptr727@users.noreply.github.com' + +# Every parameter is read into a variable here rather than from inside a function. +# A script parameter reached only from a nested scope reads as declared and never used, which is a finding on each one and hides a parameter that genuinely is unused. +$ACTIONS = [ordered]@{ + status = [bool]$Status + configure = [bool]$Configure +} +$WANT_HELP = [bool]$Help + +$MODE = 'status' +$DRY_RUN = [bool]$DryRun +$ASSUME_YES = [bool]$Yes +$WANT_NAME = $Name +$WANT_EMAIL = $Email +$SHARED = $SharedCheckout +$GITHUB_USER = '' +$MANAGED_KEY_AUTHENTICATES = $false + +# --- Output --- + +function log { param([string]$Message = '') Write-Host $Message } +function info { param([string]$Message) Write-Host " $Message" } +function step { param([string]$Message) Write-Host "`n==> $Message" } +function warn { param([string]$Message) [Console]::Error.WriteLine("WARNING: $Message") } +function die { param([string]$Message) [Console]::Error.WriteLine("ERROR: $Message"); exit 1 } + +function ok { param([string]$Message) Write-Host " [ ok ] $Message" } +function missing { param([string]$Message) Write-Host " [ ] $Message" } + +function usage { + # The closing marker of a here-string has to sit at column 0, so this block is deliberately unindented. + Write-Host @' +Usage: setup-github.ps1 [options] + +Sets up git and GitHub on this host: the SSH key, the git configuration, and commit signing. + +Actions, name one, default -Status: + -s, -Status Report what is set up and what is not, change nothing + -c, -Configure Create the key, apply the configuration, and check both registrations + -h, -Help Show this help + +Options: + -n, -DryRun Print the commands instead of running them + -y, -Yes Do not prompt before changing the host + -Name Name for git commits + -Email Email for git commits + -SharedCheckout PATH + Configure a checkout that several accounts share, at PATH. This turns off git's + ownership check for that path, so it is named rather than assumed: a host one + account uses needs it for nothing. "*" applies it to every path on the host, + which is the broadest form and is reported as such. + +The identity comes from -Name and -Email, or from what this host already carries, or from the +default the maintainer commits under, in that order. A host configured for somebody else keeps its +own identity rather than being quietly rewritten. + +Registering the key is done in a browser and cannot be automated, so -Configure stops at each +registration and prints what to paste. The same key is registered twice, once as an authentication +key and once as a signing key: authentication reaches private repositories, signing is what marks a +commit verified. Each is checked against the keys GitHub publishes for the account. + +Two host settings are reported and never written, because both need administrator and rewriting a +working host is worse than naming what to change: the ssh-agent service, and the key file's ACL. + +Examples: + setup-github.ps1 Report, change nothing + setup-github.ps1 -Configure Set the host up + setup-github.ps1 -Configure -DryRun Show what it would do + setup-github.ps1 -Configure -Email you@users.noreply.github.com +'@ +} + +# --- Execution --- + +# Run a command, or print it under -DryRun. +# A read used to decide what to do runs either way, and only a command that changes the host goes through here. +function run { + param([Parameter(Mandatory)][string]$Command, [Parameter(ValueFromRemainingArguments)][string[]]$Arguments) + if ($script:DRY_RUN) { + Write-Host " [dry run] $Command $($Arguments -join ' ')" + return 0 + } + # The command's own output goes to the console rather than down the pipeline. + # A native command writes to this function's output stream, so without this the caller receives every line the command printed with the exit code appended, and a check against 0 then compares against the first line of output. + & $Command @Arguments | Out-Host + return $LASTEXITCODE +} + +function confirm { + param([Parameter(Mandatory)][string]$Question) + if ($script:ASSUME_YES -or $script:DRY_RUN) { return $true } + # Both are checked because a scheduled task reports one and not the other, and either alone misses a case. + if (-not [Environment]::UserInteractive -or [Console]::IsInputRedirected) { + die 'Not a terminal and -Yes was not given, refusing to change the host unattended' + } + return ((Read-Host "$Question [y/N]") -match '^(y|yes)$') +} + +# --- Prerequisites --- + +# Both git and ssh ship with Git for Windows, and neither is installed from here. +# Installing them belongs to install-tools.ps1, so a gap is named with the command that closes it rather than closed twice in two scripts. +# Every executable this script calls is named, rather than the three it is most obviously about. +# One left out fails partway through with whatever that command says when it is missing, in place of the one message here that names the remedy. +function Test-Prerequisite { + $absent = @() + foreach ($tool in 'git', 'ssh', 'ssh-keygen', 'ssh-keyscan', 'ssh-add') { + if (-not (Get-Command $tool -ErrorAction SilentlyContinue)) { $absent += $tool } + } + if ($absent.Count -eq 0) { return } + die "Not found on this host: $($absent -join ', '). All of these ship with Git for Windows, which install-tools.ps1 -Install git installs." +} + +# --- Key --- + +function Get-KeyBody { + if (-not (Test-Path "$script:KEY.pub")) { return $null } + $text = (Get-Content "$script:KEY.pub" -Raw).Trim() + if (-not $text) { return $null } + return $text +} + +function New-KeyIfAbsent { + step 'Checking the SSH key' + if (Test-Path $script:KEY) { + info "Already at $($script:KEY)" + return + } + $directory = Split-Path -Parent $script:KEY + if (-not (Test-Path $directory)) { + if ($script:DRY_RUN) { info "[dry run] create $directory" } else { New-Item -ItemType Directory -Path $directory -Force | Out-Null } + } + info "Creating $($script:KEY)" + # An empty passphrase is not chosen here, so ssh-keygen asks, which is the one question only the operator can answer. + $code = run -Command 'ssh-keygen' -Arguments '-t', 'ed25519', '-C', $script:WANT_EMAIL, '-f', $script:KEY + if ($code -ne 0) { die "ssh-keygen exited $code" } +} + +# The ACL is reported and never rewritten. +# Creating the key with ssh-keygen already sets it correctly on Windows, and rewriting an ACL is hard to undo and hard to preview under -DryRun, so a wrong one is named with the command that repairs it. +function Test-KeyAcl { + if (-not (Test-Path $script:KEY)) { return $null } + $acl = Get-Acl $script:KEY + $others = @($acl.Access | Where-Object { $_.IdentityReference.Value -ne $acl.Owner -and $_.IdentityReference.Value -notmatch 'NT AUTHORITY\\SYSTEM|BUILTIN\\Administrators' }) + return ($others.Count -eq 0) +} + +function Add-KnownHost { + step 'Checking that github.com is a known host' + $known = Join-Path $HOME '.ssh\known_hosts' + if ((& ssh-keygen -F github.com 2>&1 | Out-String) -match 'found') { + info 'Already known' + return + } + info 'Adding the github.com host keys' + if ($script:DRY_RUN) { + info "[dry run] check the github.com host key against the fingerprints GitHub publishes, then record it in $known" + return + } + + # Comment lines carry the banner rather than a key, and ssh-keygen refuses a file holding one. + $scanned = @(& ssh-keyscan github.com 2> $null | Where-Object { $_ -and $_ -notmatch '^\s*#' }) + if ($scanned.Count -eq 0) { + die 'ssh-keyscan returned no host key for github.com. The OpenSSH under %SystemRoot%\System32 is older than the key exchange github.com offers and fails with "unsupported KEX method", where the copy shipped with Git for Windows under %ProgramFiles%\Git\usr\bin succeeds. Put that one first on PATH and run this again.' + } + + # What was offered is checked against what GitHub publishes before it is recorded, matching the Linux peer. + # Recording whatever answered would persist a substituted key on the one run where nothing yet pins the real one, and every later connection would then verify against it. + $temp = Join-Path ([IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString() + '.pub') + try { + [IO.File]::WriteAllText($temp, ($scanned -join "`n") + "`n") + $offered = @(& ssh-keygen -lf $temp 2> $null | + ForEach-Object { ($_ -split '\s+')[1] -replace '^SHA256:', '' } | + Where-Object { $_ } | Sort-Object -Unique) + } finally { + Remove-Item $temp -Force -ErrorAction SilentlyContinue + } + if ($offered.Count -eq 0) { die 'The host key github.com offered could not be fingerprinted, so it is not being recorded' } + + $published = @() + try { + $meta = Invoke-RestMethod -Uri 'https://api.github.com/meta' -TimeoutSec 10 + $published = @($meta.ssh_key_fingerprints.PSObject.Properties | + Where-Object { $_.Name -like 'SHA256*' } | ForEach-Object { $_.Value }) + } catch { + $published = @() + } + if ($published.Count -eq 0) { + die 'Cannot read the host key fingerprints GitHub publishes, so the key it offered cannot be checked. Compare it by hand against https://docs.github.com/authentication/keeping-your-account-secure/githubs-ssh-key-fingerprints and record it with ssh-keyscan.' + } + + foreach ($fingerprint in $offered) { + if ($published -notcontains $fingerprint) { + die "github.com offered a host key GitHub does not publish (SHA256:$fingerprint), so it is not being recorded" + } + } + info 'The offered host key matches what GitHub publishes' + + $directory = Split-Path -Parent $known + if (-not (Test-Path $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } + Add-Content -Path $known -Value $scanned +} + +# --- GitHub, read only --- + +# Every probe refuses to write a host key, so asking whether authentication works cannot enroll github.com behind the reader's back and a status run stays a read-only action. +# A status run also refuses to prompt, since it may be run unattended and a passphrase prompt would hang it. +function Get-SshGreeting { + param([switch]$ManagedKeyOnly) + $options = @('-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=10') + if ($script:MODE -eq 'status') { $options += @('-o', 'BatchMode=yes') } + if ($ManagedKeyOnly) { + if (-not (Test-Path $script:KEY)) { return '' } + # The ssh client offers the default identity files and an agent's keys besides whatever is named, so a host carrying another account's key authenticates as that account. + # An empty config file is what isolates the managed key, since the default identity files count as configured identities on their own. + $options += @('-F', 'NUL', '-o', 'IdentitiesOnly=yes', '-i', $script:KEY) + } + return (& ssh @options -T 'git@github.com' 2>&1 | Out-String) +} + +function Get-GreetingUser { + param([string]$Greeting) + if ($Greeting -match '(?m)^Hi ([^!]+)!') { return $Matches[1] } + return '' +} + +# Prefer the account the managed key belongs to, since that is the one the registration checks are about, and say so when the host answers as somebody else. +function Resolve-GitHubUser { + $general = Get-GreetingUser (Get-SshGreeting) + $managed = Get-GreetingUser (Get-SshGreeting -ManagedKeyOnly) + + $script:MANAGED_KEY_AUTHENTICATES = [bool]$managed + if ($managed) { + $script:GITHUB_USER = $managed + if ($general -and $general -ne $managed) { + warn "This host authenticates as $general with another key, while the managed key belongs to $managed" + } + return + } + $script:GITHUB_USER = $general +} + +# GitHub publishes both key lists for an account, so a registration can be checked from the host without a token and without the browser that made it. +# Each returns registered, missing, or unknown, and the third is not the second: a momentary failure to reach GitHub reported as "not registered" sends the reader to register a key that is already there. +# PowerShell separates those two by itself, since an account with no signing key returns an empty list where an unreachable GitHub throws. +function Test-KeyRegistered { + param([ValidateSet('auth', 'signing')][string]$Kind) + $body = Get-KeyBody + if (-not $body -or -not $script:GITHUB_USER) { return 'unknown' } + # The comment field is not part of what GitHub publishes, so only the type and the key itself are compared. + $wanted = (($body -split '\s+') | Select-Object -First 2) -join ' ' + try { + if ($Kind -eq 'auth') { + $keys = (Invoke-RestMethod -Uri "https://github.com/$($script:GITHUB_USER).keys" -TimeoutSec 10) -split "`n" + } else { + $keys = @(Invoke-RestMethod -Uri "https://api.github.com/users/$($script:GITHUB_USER)/ssh_signing_keys" -TimeoutSec 10 | ForEach-Object { $_.key }) + } + } catch { + return 'unknown' + } + foreach ($key in $keys) { + if ($key.Trim() -eq $wanted) { return 'registered' } + } + return 'missing' +} + +# A registration is a browser step, so this prints what to paste and where, then stops. +function Show-RegistrationNeeded { + param([string]$Kind, [string]$Type) + log '' + log "The key is not registered for $Kind. This step happens in a browser:" + info "1. Open $($script:KEY_SETTINGS_URL)" + info "2. Set the key type to `"$Type key`"" + info "3. Paste the key below, and give it this host's name" + log '' + # A dry run reports what it would create rather than creating it, so the key this block exists to print may not be there. + $body = Get-KeyBody + if ($body) { log $body } else { info "No key at $($script:KEY).pub yet, so there is nothing to paste. A run that is not a dry run creates it." } + log '' +} + +# --- git configuration --- + +function Get-GitConfig { + param([string]$Key) + $value = (& git config --global --get $Key 2>$null | Out-String).Trim() + return $value +} + +# Set a value only where it differs, so a re-run is silent rather than rewriting the same file. +function Set-GitConfig { + param([string]$Key, [string]$Value) + if ((Get-GitConfig $Key) -eq $Value) { return $false } + run -Command 'git' -Arguments 'config', '--global', $Key, $Value | Out-Null + return $true +} + +# The safe.directory setting is multi valued, so setting it again appends a duplicate rather than replacing it. +function Add-GitConfigOnce { + param([string]$Key, [string]$Value) + $current = @(& git config --global --get-all $Key 2>$null) + if ($current -contains $Value) { return $false } + run -Command 'git' -Arguments 'config', '--global', '--add', $Key, $Value | Out-Null + return $true +} + +# The flag wins, then whatever the host already carries, then the default. +# Reading the host first is what keeps a machine configured for somebody else from being rewritten by a run meant to be safe to repeat. +function Resolve-Identity { + if (-not $script:WANT_NAME) { + $existing = Get-GitConfig 'user.name' + $script:WANT_NAME = if ($existing) { $existing } else { $script:DEFAULT_NAME } + } + if (-not $script:WANT_EMAIL) { + $existing = Get-GitConfig 'user.email' + $script:WANT_EMAIL = if ($existing) { $existing } else { $script:DEFAULT_EMAIL } + } +} + +function Set-GitIdentity { + step 'Configuring git' + info "Identity: $($script:WANT_NAME) <$($script:WANT_EMAIL)>" + + $changed = 0 + if (Set-GitConfig 'user.name' $script:WANT_NAME) { $changed++ } + if (Set-GitConfig 'user.email' $script:WANT_EMAIL) { $changed++ } + # Git Credential Manager ships with Git for Windows and is what gh expects there, where the Linux peer writes a cache helper because no manager exists. + # Written only where nothing is set, so a host already carrying a helper keeps it. + if (-not (Get-GitConfig 'credential.helper')) { + if (Set-GitConfig 'credential.helper' 'manager') { $changed++ } + } + + if ($changed -eq 0) { info 'Already configured' } else { info "$changed setting(s) written" } +} + +# A checkout several accounts share needs two settings that are relaxations rather than defaults, so they are applied only for a path the caller names. +function Set-SharedCheckout { + if (-not $script:SHARED) { return } + step "Configuring the shared checkout at $($script:SHARED)" + + $changed = 0 + if (Set-GitConfig 'core.sharedRepository' 'group') { $changed++ } + if (Add-GitConfigOnce 'safe.directory' $script:SHARED) { $changed++ } + + if ($script:SHARED -eq '*') { + warn 'safe.directory is set to every path on this host, which turns the ownership check off everywhere' + } elseif (-not (Test-Path $script:SHARED)) { + info "$($script:SHARED) does not exist yet, and the setting waits for it" + } + + if ($changed -eq 0) { info 'Already configured' } else { info "$changed setting(s) written" } +} + +function Set-Signing { + step 'Configuring commit signing' + + $changed = 0 + if (Set-GitConfig 'gpg.format' 'ssh') { $changed++ } + if (Set-GitConfig 'user.signingkey' $script:KEY_SETTING) { $changed++ } + if (Set-GitConfig 'commit.gpgsign' 'true') { $changed++ } + if (Set-GitConfig 'tag.gpgsign' 'true') { $changed++ } + if (Set-GitConfig 'gpg.ssh.allowedSignersFile' $script:ALLOWED_SIGNERS_SETTING) { $changed++ } + + # The allowed signers file is what verifies a signature locally, and it is appended to rather than rewritten, since it can carry other identities. + $body = Get-KeyBody + if ($body) { + $entry = "$($script:WANT_EMAIL) namespaces=`"git`" $body" + $existing = if (Test-Path $script:ALLOWED_SIGNERS) { @(Get-Content $script:ALLOWED_SIGNERS) } else { @() } + if ($existing -notcontains $entry) { + if ($script:DRY_RUN) { + info "[dry run] append this host's key to $($script:ALLOWED_SIGNERS)" + } else { + $directory = Split-Path -Parent $script:ALLOWED_SIGNERS + if (-not (Test-Path $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } + Add-Content -Path $script:ALLOWED_SIGNERS -Value $entry + } + $changed++ + } + } + + if ($changed -eq 0) { info 'Already configured' } else { info "$changed setting(s) written" } +} + +# Sign a commit in a throwaway repository and verify it. +# This proves the configuration end to end, which reading the settings back cannot: a wrong allowed signers entry reads as correct and fails only when a signature is checked. +function Test-Signing { + $repository = Join-Path ([IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString()) + try { + New-Item -ItemType Directory -Path $repository -Force | Out-Null + & git init -q $repository 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { return $false } + & git -C $repository commit -q --allow-empty -m 'signing check' 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { return $false } + & git -C $repository verify-commit HEAD 2>&1 | Out-Null + return ($LASTEXITCODE -eq 0) + } catch { + return $false + } finally { + Remove-Item $repository -Recurse -Force -ErrorAction SilentlyContinue + } +} + +# --- Agent --- + +# On Windows the agent is a service rather than a socket, and both starting it and setting it to start automatically need administrator. +# Reported with the command that fixes it rather than run, on the same rule as the key ACL: this script does not elevate, and naming the change is better than half applying it. +function Show-AgentStatus { + log '' + log 'SSH agent' + $service = Get-Service ssh-agent -ErrorAction SilentlyContinue + if (-not $service) { + missing 'the ssh-agent service exists, and OpenSSH is installed as a Windows optional feature' + return + } + if ($service.Status -eq 'Running') { + ok 'the ssh-agent service is running' + } else { + missing "the ssh-agent service is running, it is $($service.Status), start it with: Set-Service ssh-agent -StartupType Automatic; Start-Service ssh-agent" + return + } + + $body = Get-KeyBody + $loaded = (& ssh-add -L 2>&1 | Out-String) + if ($body -and $loaded -match [regex]::Escape((($body -split '\s+') | Select-Object -First 2) -join ' ')) { + ok 'the managed key is loaded in the agent' + } else { + missing "the managed key is loaded in the agent, add it with: ssh-add $($script:KEY)" + } +} + +# --- Actions --- + +function Show-Status { + log 'Host identity' + foreach ($tool in 'git', 'ssh') { + if (Get-Command $tool -ErrorAction SilentlyContinue) { ok "$tool installed" } else { missing "$tool installed, which install-tools.ps1 -Install git provides" } + } + if (Test-Path $script:KEY) { ok "SSH key at $($script:KEY)" } else { missing "SSH key at $($script:KEY)" } + + $acl = Test-KeyAcl + if ($null -eq $acl) { missing 'the key file ACL, unknown until the key exists' } + elseif ($acl) { ok 'the key file is readable only by its owner' } + else { missing "the key file is readable only by its owner, repair it with: icacls `"$($script:KEY)`" /inheritance:r /grant:r `"`$env:USERNAME:F`"" } + + $known = (& ssh-keygen -F github.com 2>&1 | Out-String) -match 'found' + Resolve-GitHubUser + if ($script:GITHUB_USER) { ok "SSH authentication to GitHub, as $($script:GITHUB_USER)" } + elseif (-not $known) { missing 'SSH authentication to GitHub, unchecked because github.com is not in known_hosts, which -Configure adds' } + else { missing 'SSH authentication to GitHub' } + + if ($script:MANAGED_KEY_AUTHENTICATES) { ok 'The managed key is the one that authenticates' } + elseif ($script:GITHUB_USER) { missing 'The managed key authenticates, so the account above answered with another key or through this host ssh config' } + else { missing 'The managed key authenticates' } + + log '' + log 'Registration, as GitHub publishes it' + # Which account to ask about comes from authentication, so with none there is no question to ask, and that reads differently from having asked and failed. + if (-not $script:GITHUB_USER -or -not (Get-KeyBody)) { + missing 'Authentication key registered, unknown until authentication works' + missing 'Signing key registered, unknown until authentication works' + } else { + foreach ($pair in @(@('auth', 'Authentication'), @('signing', 'Signing'))) { + switch (Test-KeyRegistered -Kind $pair[0]) { + 'registered' { ok "$($pair[1]) key registered" } + 'unknown' { missing "$($pair[1]) key registered, GitHub could not be reached to check" } + default { missing "$($pair[1]) key registered" } + } + } + } + + log '' + log 'git configuration' + foreach ($key in 'user.name', 'user.email', 'credential.helper', 'gpg.format', + 'user.signingkey', 'commit.gpgsign', 'tag.gpgsign', 'gpg.ssh.allowedSignersFile') { + $value = Get-GitConfig $key + if ($value) { ok "$key = $value" } else { missing $key } + } + + # The shared checkout settings are reported apart, because absent is the right state for a host one account uses and listing them as missing would read as two gaps to close. + $shared = @(& git config --global --get-all safe.directory 2>$null) + if ($shared.Count -gt 0) { + $unique = @($shared | Sort-Object -Unique) + ok "safe.directory = $($unique -join ' ')" + if ($shared.Count -gt $unique.Count) { + info "$($shared.Count) entries for $($unique.Count) path(s), so this setting was added more than once" + } + } else { + info '[ ] safe.directory not set, which -SharedCheckout <path> sets where a checkout is shared' + } + + Show-AgentStatus + + log '' + log 'GitHub CLI' + if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { + missing 'gh installed, which install-tools.ps1 -Install gh provides' + } else { + $auth = (& gh auth status 2>&1 | Out-String) + if ($auth -match 'Logged in to \S+ account (\S+)') { ok "authenticated as $($Matches[1])" } else { missing 'authenticated, log in with: gh auth login --hostname github.com --git-protocol ssh' } + # Reported and never set, matching the Linux peer, which touches gh nowhere. + # An https protocol makes a checkout made through gh authenticate by token where every other checkout on the host authenticates by key. + if ($auth -match 'Git operations protocol: (\S+)') { + if ($Matches[1] -eq 'ssh') { ok 'git protocol is ssh' } + else { missing "git protocol is ssh, it is $($Matches[1]), set it with: gh config set git_protocol ssh" } + } + } + + log '' + log 'Signing' + if (Test-Signing) { ok 'A commit signs and verifies on this host' } else { missing 'A commit signs and verifies on this host' } +} + +function Invoke-Configure { + Test-Prerequisite + Resolve-Identity + New-KeyIfAbsent + Add-KnownHost + + # The local configuration comes first, because none of it needs GitHub. + # A host that cannot authenticate yet, which is every host between creating its key and registering it, still ends this run with its identity, its signing configuration, and a commit that verifies locally. + Set-GitIdentity + Set-SharedCheckout + Set-Signing + + step 'Signing a commit to check the configuration' + if ($script:DRY_RUN) { + info '[dry run] sign and verify a commit in a throwaway repository' + } elseif (Test-Signing) { + info 'A commit signs and verifies' + } else { + die "A commit did not verify. Check $($script:ALLOWED_SIGNERS) holds this host key against $($script:WANT_EMAIL)." + } + + step 'Checking the key registrations at GitHub' + Resolve-GitHubUser + if (-not $script:GITHUB_USER) { + Show-RegistrationNeeded -Kind 'authentication' -Type 'Authentication' + warn 'Authentication does not work yet, so nothing that reaches GitHub will work until the key above is registered' + return + } + info "Authenticated as $($script:GITHUB_USER)" + + if ((Test-KeyRegistered -Kind 'signing') -eq 'missing') { + Show-RegistrationNeeded -Kind 'signing' -Type 'Signing' + warn 'Commits sign and verify locally, and show as unverified on GitHub until the key above is registered as a signing key' + } else { + info 'The signing key is registered' + } + + Show-AgentStatus + step 'Done' +} + +# --- Entry --- + +# PowerShell records which switches were given and not the order they came in, so two actions is a refusal rather than the last one winning. +# Refusing is also the better answer: an action silently discarded is one the caller believes ran. +function Resolve-Mode { + $given = @($script:ACTIONS.Keys | Where-Object { $script:ACTIONS[$_] }) + if ($given.Count -gt 1) { die "More than one action given ($($given -join ', ')), name one" } + if ($given.Count -eq 0) { return 'status' } + return $given[0] +} + +function main { + if ($script:WANT_HELP) { usage; exit 0 } + $script:MODE = Resolve-Mode + Test-Prerequisite + + if ($script:MODE -eq 'status') { Show-Status } else { Invoke-Configure } +} + +main diff --git a/host-setup/windows/setup-wsl.ps1 b/host-setup/windows/setup-wsl.ps1 index 50f6dac1..1de1958e 100644 --- a/host-setup/windows/setup-wsl.ps1 +++ b/host-setup/windows/setup-wsl.ps1 @@ -1,350 +1,350 @@ -# Installs the WSL distributions this host runs, and reports how Docker Desktop is integrated with them. -# It is a fourth script rather than part of upgrade-host.ps1, because installing a distribution stands a new environment up where a platform update brings this one current, and those are different actions on different subjects. -# -# The Docker integration is reported and never written. -# Docker Desktop holds these settings in memory and rewrites its settings file from that copy while it runs, so an edit made here is discarded at Docker's next save and an edit made while it is stopped is undone by the next start. - -[CmdletBinding()] -param( - [Alias('s')][switch]$Status, - [Alias('l')][switch]$List, - [Alias('i')][switch]$Install, - [switch]$Default, - [Alias('n')][switch]$DryRun, - [Alias('y')][switch]$Yes, - [Alias('h')][switch]$Help, - [Parameter(Position = 0, ValueFromRemainingArguments)][string[]]$Name -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' -# A non-zero exit from wsl is an answer here rather than a failure. -# Setting this keeps a profile that turned it on from turning every read into a terminating error. -$PSNativeCommandUseErrorActionPreference = $false - -# Docker Desktop registers these itself, so they are not distributions the operator installed and a report that named them would count two as three. -$DOCKER_DISTRIBUTIONS = @('docker-desktop', 'docker-desktop-data') - -$SETTINGS = Join-Path $env:APPDATA 'Docker\settings-store.json' - -# Every parameter is read into a variable here rather than from inside a function. -# A script parameter reached only from a nested scope reads as declared and never used, which is a finding on each one and hides a parameter that genuinely is unused. -$ACTIONS = [ordered]@{ - status = [bool]$Status - list = [bool]$List - install = [bool]$Install -} -$WANT_HELP = [bool]$Help -$WANT_DEFAULT = [bool]$Default -# Filtered rather than wrapped, because wrapping an unset parameter yields a one element list holding nothing, which reads as one distribution named the empty string. -$WANT_NAMES = @($Name | Where-Object { $_ }) - -$MODE = 'status' -$DRY_RUN = [bool]$DryRun -$ASSUME_YES = [bool]$Yes - -# --- Output --- - -function log { param([string]$Message = '') Write-Host $Message } -function info { param([string]$Message) Write-Host " $Message" } -function step { param([string]$Message) Write-Host "`n==> $Message" } -function warn { param([string]$Message) [Console]::Error.WriteLine("WARNING: $Message") } -function die { param([string]$Message) [Console]::Error.WriteLine("ERROR: $Message"); exit 1 } - -function ok { param([string]$Message) Write-Host " [ ok ] $Message" } -function missing { param([string]$Message) Write-Host " [ ] $Message" } - -function usage { - # The closing marker of a here-string has to sit at column 0, so this block is deliberately unindented. - Write-Host @' -Usage: setup-wsl.ps1 [options] [distribution] - -Installs a WSL distribution, and reports the ones this host runs and how Docker Desktop is -integrated with them. - -Actions, name one, default -Status: - -s, -Status Report the platform, the distributions, and the Docker integration - -l, -List List the distributions this host can install - -i, -Install Install the named distribution - -h, -Help Show this help - -Options: - -n, -DryRun Print the commands instead of running them - -y, -Yes Do not prompt before changing the host - -Default Also make the installed distribution the default - -An install does not launch the distribution, so it skips the first run account setup and is safe -unattended. The new distribution has no user until it is launched once, which is a person's step. - -The Docker Desktop integration is reported and never written, because Docker rewrites its settings -file from memory while it runs, so an edit made here does not survive. Change it in Docker Desktop -under Settings, Resources, WSL integration. - -Examples: - setup-wsl.ps1 Report the platform and the distributions - setup-wsl.ps1 -List List what can be installed - setup-wsl.ps1 -Install Debian Install Debian, without launching it - setup-wsl.ps1 -Install Ubuntu-24.04 -Default - setup-wsl.ps1 -Install Debian -DryRun -'@ -} - -# --- Execution --- - -# Run a command, or print it under -DryRun. -# A read used to decide what to do runs either way, and only a command that changes the host goes through here. -function run { - param([Parameter(Mandatory)][string]$Command, [Parameter(ValueFromRemainingArguments)][string[]]$Arguments) - if ($script:DRY_RUN) { - Write-Host " [dry run] $Command $($Arguments -join ' ')" - return 0 - } - # The command's own output goes to the console rather than down the pipeline. - # A native command writes to this function's output stream, so without this the caller receives every line the command printed with the exit code appended, and a check against 0 then compares against the first line of output. - & $Command @Arguments | Out-Host - return $LASTEXITCODE -} - -function confirm { - param([Parameter(Mandatory)][string]$Question) - if ($script:ASSUME_YES -or $script:DRY_RUN) { return $true } - # Both are checked because a scheduled task reports one and not the other, and either alone misses a case. - if (-not [Environment]::UserInteractive -or [Console]::IsInputRedirected) { - die 'Not a terminal and -Yes was not given, refusing to change the host unattended' - } - return ((Read-Host "$Question [y/N]") -match '^(y|yes)$') -} - -# --- WSL --- - -# Every wsl.exe call goes through here, because wsl.exe emits UTF-16 by default and its output then reads as NUL separated characters. -# WSL_UTF8 changes what wsl.exe emits, where setting the console encoding would only change how this process decodes it and would corrupt in-distribution output that is already UTF-8. -# A host whose WSL predates WSL_UTF8 still answers in UTF-16, so a result carrying a NUL is stripped rather than reported as unreadable. -function Invoke-Wsl { - param([Parameter(ValueFromRemainingArguments)][string[]]$Arguments) - $previous = $env:WSL_UTF8 - try { - $env:WSL_UTF8 = '1' - $text = (& wsl.exe @Arguments 2>&1 | Out-String -Width 500) - if ($text.Contains([char]0)) { $text = $text -replace "`0", '' } - return $text - } finally { - if ($null -eq $previous) { Remove-Item Env:\WSL_UTF8 -ErrorAction SilentlyContinue } - else { $env:WSL_UTF8 = $previous } - } -} - -function Test-WslPresent { - return [bool](Get-Command wsl.exe -ErrorAction SilentlyContinue) -} - -function Get-WslPlatform { - $platform = [ordered]@{} - $text = Invoke-Wsl '--version' - if ($LASTEXITCODE -ne 0) { return $platform } - foreach ($label in 'WSL version', 'Kernel version', 'WSLg version', 'Windows version') { - if ($text -match "(?m)^$label`:\s*(\S+)\s*$") { $platform[$label] = $Matches[1] } - } - return $platform -} - -# The distributions this host has registered, with Docker's own excluded. -# The verbose listing marks the default with a leading asterisk, which is the only place that fact is published. -function Get-WslDistribution { - $rows = @() - $text = Invoke-Wsl '--list' '--verbose' - if ($LASTEXITCODE -ne 0) { return , $rows } - foreach ($line in ($text -split "`r?`n")) { - if ($line -notmatch '^(\*?)\s+(\S+)\s+(\S+)\s+(\d+)\s*$') { continue } - $distribution = $Matches[2] - if ($script:DOCKER_DISTRIBUTIONS -contains $distribution) { continue } - $rows += @{ - Name = $distribution - State = $Matches[3] - Version = $Matches[4] - IsDefault = ($Matches[1] -eq '*') - } - } - return , $rows -} - -function Get-WslAvailable { - $rows = @() - $text = Invoke-Wsl '--list' '--online' - if ($LASTEXITCODE -ne 0) { return , $rows } - $started = $false - foreach ($line in ($text -split "`r?`n")) { - if ($line -match '^NAME\s+FRIENDLY NAME') { $started = $true; continue } - if (-not $started) { continue } - if ($line -notmatch '^(\S+)\s\s+(.+?)\s*$') { continue } - $rows += @{ Name = $Matches[1]; Friendly = $Matches[2] } - } - return , $rows -} - -# --- Docker --- - -# How Docker Desktop records its WSL integration, read from its settings file. -# Reported and never written: Docker holds these in memory and rewrites the file from that copy while it runs, so an edit here is discarded at its next save. -function Get-DockerIntegration { - if (-not (Test-Path $script:SETTINGS)) { return $null } - try { - return (Get-Content $script:SETTINGS -Raw | ConvertFrom-Json) - } catch { - warn "Docker's settings file at $($script:SETTINGS) could not be read as JSON, so the integration is not reported: $($_.Exception.Message)" - return $null - } -} - -function Get-JsonMember { - param($Object, [string]$Member) - if ($null -eq $Object) { return $null } - $property = $Object.PSObject.Properties[$Member] - if ($null -eq $property) { return $null } - return $property.Value -} - -# --- Actions --- - -function Show-Platform { - log 'Platform' - $platform = Get-WslPlatform - if ($platform.Count -eq 0) { - info 'WSL is installed but did not report a version, so it may need "wsl --update"' - return - } - foreach ($label in $platform.Keys) { info ("{0,-16}{1}" -f $label, $platform[$label]) } -} - -function Show-Distribution { - log '' - log 'Distributions' - $rows = Get-WslDistribution - if ($rows.Count -eq 0) { - info 'None registered, and -List names what this host can install' - return , $rows - } - $format = ' {0,-16} {1,-12} {2,-9} {3}' - log ($format -f 'NAME', 'STATE', 'VERSION', 'DEFAULT') - foreach ($row in $rows) { - log ($format -f $row.Name, $row.State, $row.Version, $(if ($row.IsDefault) { 'yes' } else { '' })) - } - return , $rows -} - -function Show-Integration { - param([array]$Distribution) - log '' - log 'Docker Desktop integration, as Docker records it' - - $settings = Get-DockerIntegration - if ($null -eq $settings) { - info 'Docker Desktop is not installed here, or has never been started, so there is no integration to report' - return - } - - $engine = Get-JsonMember $settings 'WslEngineEnabled' - if ($engine) { ok 'WSL engine enabled' } else { missing 'WSL engine enabled' } - - $withDefault = Get-JsonMember $settings 'EnableIntegrationWithDefaultWslDistro' - if ($withDefault) { ok 'integration with the default distribution' } else { missing 'integration with the default distribution' } - - $integrated = @(Get-JsonMember $settings 'IntegratedWslDistros') - foreach ($row in $Distribution) { - if ($integrated -contains $row.Name) { ok $row.Name } else { missing $row.Name } - } - - if (Get-JsonMember $settings 'WslUpdateRequired') { - warn 'Docker reports that WSL needs updating, which upgrade-host.ps1 -Wsl does once Docker is quit' - } - - log '' - info 'Change this in Docker Desktop under Settings, Resources, WSL integration.' - info 'It is reported here and never written, because Docker rewrites its settings file from memory while it runs.' -} - -function Show-Status { - if (-not (Test-WslPresent)) { - die 'wsl.exe not found, so WSL is not installed on this host. Install it with: wsl --install --no-distribution' - } - Show-Platform - $distribution = Show-Distribution - Show-Integration -Distribution $distribution -} - -function Show-List { - if (-not (Test-WslPresent)) { - die 'wsl.exe not found, so WSL is not installed on this host. Install it with: wsl --install --no-distribution' - } - $rows = Get-WslAvailable - if ($rows.Count -eq 0) { - die 'WSL listed no installable distributions, which usually means it could not reach the distribution index' - } - $format = '{0,-32} {1}' - log ($format -f 'NAME', 'FRIENDLY NAME') - foreach ($row in $rows) { log ($format -f $row.Name, $row.Friendly) } -} - -function Install-Distribution { - if (-not (Test-WslPresent)) { - die 'wsl.exe not found, so WSL is not installed on this host. Install it with: wsl --install --no-distribution' - } - if ($script:WANT_NAMES.Count -ne 1) { - die 'Name one distribution to install, and -List names the ones this host can install' - } - $wanted = $script:WANT_NAMES[0] - - # Checked against the index rather than left to wsl, whose failure for an unknown name is a long help text rather than a message naming the problem. - $available = Get-WslAvailable - if ($available.Count -gt 0 -and ($available | ForEach-Object { $_.Name }) -notcontains $wanted) { - die "`"$wanted`" is not a distribution this host can install, and -List names the ones it can" - } - - $installed = Get-WslDistribution - if (($installed | ForEach-Object { $_.Name }) -contains $wanted) { - log "$wanted is already installed, leaving it alone" - return - } - - # The name is wrapped because a question mark is a legal character in a variable name, so "$wanted?" reads as a variable nobody set. - if (-not (confirm "Install $($wanted)?")) { die 'Declined' } - - step "Installing $wanted" - # The first run account setup is skipped, which is what makes this safe unattended, and it leaves the distribution with no user until somebody launches it. - $code = run -Command 'wsl.exe' -Arguments '--install', $wanted, '--no-launch' - if ($code -ne 0) { die "wsl --install exited $code" } - - if ($script:WANT_DEFAULT) { - step "Making $wanted the default distribution" - $code = run -Command 'wsl.exe' -Arguments '--set-default', $wanted - if ($code -ne 0) { warn "wsl --set-default exited $code" } - } - - step 'Done' - info "Launch it once with `"wsl -d $wanted`" to create its user account, which this deliberately did not do" - info 'Enable it for Docker in Docker Desktop under Settings, Resources, WSL integration' -} - -# --- Entry --- - -# PowerShell records which switches were given and not the order they came in, so two actions is a refusal rather than the last one winning. -# Refusing is also the better answer: an action silently discarded is one the caller believes ran. -function Resolve-Mode { - $given = @($script:ACTIONS.Keys | Where-Object { $script:ACTIONS[$_] }) - if ($given.Count -gt 1) { die "More than one action given ($($given -join ', ')), name one" } - if ($given.Count -eq 0) { return 'status' } - return $given[0] -} - -function main { - if ($script:WANT_HELP) { usage; exit 0 } - $script:MODE = Resolve-Mode - - switch ($script:MODE) { - 'list' { Show-List } - 'install' { Install-Distribution } - default { Show-Status } - } -} - -main +# Installs the WSL distributions this host runs, and reports how Docker Desktop is integrated with them. +# It is a fourth script rather than part of upgrade-host.ps1, because installing a distribution stands a new environment up where a platform update brings this one current, and those are different actions on different subjects. +# +# The Docker integration is reported and never written. +# Docker Desktop holds these settings in memory and rewrites its settings file from that copy while it runs, so an edit made here is discarded at Docker's next save and an edit made while it is stopped is undone by the next start. + +[CmdletBinding()] +param( + [Alias('s')][switch]$Status, + [Alias('l')][switch]$List, + [Alias('i')][switch]$Install, + [switch]$Default, + [Alias('n')][switch]$DryRun, + [Alias('y')][switch]$Yes, + [Alias('h')][switch]$Help, + [Parameter(Position = 0, ValueFromRemainingArguments)][string[]]$Name +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +# A non-zero exit from wsl is an answer here rather than a failure. +# Setting this keeps a profile that turned it on from turning every read into a terminating error. +$PSNativeCommandUseErrorActionPreference = $false + +# Docker Desktop registers these itself, so they are not distributions the operator installed and a report that named them would count two as three. +$DOCKER_DISTRIBUTIONS = @('docker-desktop', 'docker-desktop-data') + +$SETTINGS = Join-Path $env:APPDATA 'Docker\settings-store.json' + +# Every parameter is read into a variable here rather than from inside a function. +# A script parameter reached only from a nested scope reads as declared and never used, which is a finding on each one and hides a parameter that genuinely is unused. +$ACTIONS = [ordered]@{ + status = [bool]$Status + list = [bool]$List + install = [bool]$Install +} +$WANT_HELP = [bool]$Help +$WANT_DEFAULT = [bool]$Default +# Filtered rather than wrapped, because wrapping an unset parameter yields a one element list holding nothing, which reads as one distribution named the empty string. +$WANT_NAMES = @($Name | Where-Object { $_ }) + +$MODE = 'status' +$DRY_RUN = [bool]$DryRun +$ASSUME_YES = [bool]$Yes + +# --- Output --- + +function log { param([string]$Message = '') Write-Host $Message } +function info { param([string]$Message) Write-Host " $Message" } +function step { param([string]$Message) Write-Host "`n==> $Message" } +function warn { param([string]$Message) [Console]::Error.WriteLine("WARNING: $Message") } +function die { param([string]$Message) [Console]::Error.WriteLine("ERROR: $Message"); exit 1 } + +function ok { param([string]$Message) Write-Host " [ ok ] $Message" } +function missing { param([string]$Message) Write-Host " [ ] $Message" } + +function usage { + # The closing marker of a here-string has to sit at column 0, so this block is deliberately unindented. + Write-Host @' +Usage: setup-wsl.ps1 [options] [distribution] + +Installs a WSL distribution, and reports the ones this host runs and how Docker Desktop is +integrated with them. + +Actions, name one, default -Status: + -s, -Status Report the platform, the distributions, and the Docker integration + -l, -List List the distributions this host can install + -i, -Install Install the named distribution + -h, -Help Show this help + +Options: + -n, -DryRun Print the commands instead of running them + -y, -Yes Do not prompt before changing the host + -Default Also make the installed distribution the default + +An install does not launch the distribution, so it skips the first run account setup and is safe +unattended. The new distribution has no user until it is launched once, which is a person's step. + +The Docker Desktop integration is reported and never written, because Docker rewrites its settings +file from memory while it runs, so an edit made here does not survive. Change it in Docker Desktop +under Settings, Resources, WSL integration. + +Examples: + setup-wsl.ps1 Report the platform and the distributions + setup-wsl.ps1 -List List what can be installed + setup-wsl.ps1 -Install Debian Install Debian, without launching it + setup-wsl.ps1 -Install Ubuntu-24.04 -Default + setup-wsl.ps1 -Install Debian -DryRun +'@ +} + +# --- Execution --- + +# Run a command, or print it under -DryRun. +# A read used to decide what to do runs either way, and only a command that changes the host goes through here. +function run { + param([Parameter(Mandatory)][string]$Command, [Parameter(ValueFromRemainingArguments)][string[]]$Arguments) + if ($script:DRY_RUN) { + Write-Host " [dry run] $Command $($Arguments -join ' ')" + return 0 + } + # The command's own output goes to the console rather than down the pipeline. + # A native command writes to this function's output stream, so without this the caller receives every line the command printed with the exit code appended, and a check against 0 then compares against the first line of output. + & $Command @Arguments | Out-Host + return $LASTEXITCODE +} + +function confirm { + param([Parameter(Mandatory)][string]$Question) + if ($script:ASSUME_YES -or $script:DRY_RUN) { return $true } + # Both are checked because a scheduled task reports one and not the other, and either alone misses a case. + if (-not [Environment]::UserInteractive -or [Console]::IsInputRedirected) { + die 'Not a terminal and -Yes was not given, refusing to change the host unattended' + } + return ((Read-Host "$Question [y/N]") -match '^(y|yes)$') +} + +# --- WSL --- + +# Every wsl.exe call goes through here, because wsl.exe emits UTF-16 by default and its output then reads as NUL separated characters. +# WSL_UTF8 changes what wsl.exe emits, where setting the console encoding would only change how this process decodes it and would corrupt in-distribution output that is already UTF-8. +# A host whose WSL predates WSL_UTF8 still answers in UTF-16, so a result carrying a NUL is stripped rather than reported as unreadable. +function Invoke-Wsl { + param([Parameter(ValueFromRemainingArguments)][string[]]$Arguments) + $previous = $env:WSL_UTF8 + try { + $env:WSL_UTF8 = '1' + $text = (& wsl.exe @Arguments 2>&1 | Out-String -Width 500) + if ($text.Contains([char]0)) { $text = $text -replace "`0", '' } + return $text + } finally { + if ($null -eq $previous) { Remove-Item Env:\WSL_UTF8 -ErrorAction SilentlyContinue } + else { $env:WSL_UTF8 = $previous } + } +} + +function Test-WslPresent { + return [bool](Get-Command wsl.exe -ErrorAction SilentlyContinue) +} + +function Get-WslPlatform { + $platform = [ordered]@{} + $text = Invoke-Wsl '--version' + if ($LASTEXITCODE -ne 0) { return $platform } + foreach ($label in 'WSL version', 'Kernel version', 'WSLg version', 'Windows version') { + if ($text -match "(?m)^$label`:\s*(\S+)\s*$") { $platform[$label] = $Matches[1] } + } + return $platform +} + +# The distributions this host has registered, with Docker's own excluded. +# The verbose listing marks the default with a leading asterisk, which is the only place that fact is published. +function Get-WslDistribution { + $rows = @() + $text = Invoke-Wsl '--list' '--verbose' + if ($LASTEXITCODE -ne 0) { return , $rows } + foreach ($line in ($text -split "`r?`n")) { + if ($line -notmatch '^(\*?)\s+(\S+)\s+(\S+)\s+(\d+)\s*$') { continue } + $distribution = $Matches[2] + if ($script:DOCKER_DISTRIBUTIONS -contains $distribution) { continue } + $rows += @{ + Name = $distribution + State = $Matches[3] + Version = $Matches[4] + IsDefault = ($Matches[1] -eq '*') + } + } + return , $rows +} + +function Get-WslAvailable { + $rows = @() + $text = Invoke-Wsl '--list' '--online' + if ($LASTEXITCODE -ne 0) { return , $rows } + $started = $false + foreach ($line in ($text -split "`r?`n")) { + if ($line -match '^NAME\s+FRIENDLY NAME') { $started = $true; continue } + if (-not $started) { continue } + if ($line -notmatch '^(\S+)\s\s+(.+?)\s*$') { continue } + $rows += @{ Name = $Matches[1]; Friendly = $Matches[2] } + } + return , $rows +} + +# --- Docker --- + +# How Docker Desktop records its WSL integration, read from its settings file. +# Reported and never written: Docker holds these in memory and rewrites the file from that copy while it runs, so an edit here is discarded at its next save. +function Get-DockerIntegration { + if (-not (Test-Path $script:SETTINGS)) { return $null } + try { + return (Get-Content $script:SETTINGS -Raw | ConvertFrom-Json) + } catch { + warn "Docker's settings file at $($script:SETTINGS) could not be read as JSON, so the integration is not reported: $($_.Exception.Message)" + return $null + } +} + +function Get-JsonMember { + param($Object, [string]$Member) + if ($null -eq $Object) { return $null } + $property = $Object.PSObject.Properties[$Member] + if ($null -eq $property) { return $null } + return $property.Value +} + +# --- Actions --- + +function Show-Platform { + log 'Platform' + $platform = Get-WslPlatform + if ($platform.Count -eq 0) { + info 'WSL is installed but did not report a version, so it may need "wsl --update"' + return + } + foreach ($label in $platform.Keys) { info ("{0,-16}{1}" -f $label, $platform[$label]) } +} + +function Show-Distribution { + log '' + log 'Distributions' + $rows = Get-WslDistribution + if ($rows.Count -eq 0) { + info 'None registered, and -List names what this host can install' + return , $rows + } + $format = ' {0,-16} {1,-12} {2,-9} {3}' + log ($format -f 'NAME', 'STATE', 'VERSION', 'DEFAULT') + foreach ($row in $rows) { + log ($format -f $row.Name, $row.State, $row.Version, $(if ($row.IsDefault) { 'yes' } else { '' })) + } + return , $rows +} + +function Show-Integration { + param([array]$Distribution) + log '' + log 'Docker Desktop integration, as Docker records it' + + $settings = Get-DockerIntegration + if ($null -eq $settings) { + info 'Docker Desktop is not installed here, or has never been started, so there is no integration to report' + return + } + + $engine = Get-JsonMember $settings 'WslEngineEnabled' + if ($engine) { ok 'WSL engine enabled' } else { missing 'WSL engine enabled' } + + $withDefault = Get-JsonMember $settings 'EnableIntegrationWithDefaultWslDistro' + if ($withDefault) { ok 'integration with the default distribution' } else { missing 'integration with the default distribution' } + + $integrated = @(Get-JsonMember $settings 'IntegratedWslDistros') + foreach ($row in $Distribution) { + if ($integrated -contains $row.Name) { ok $row.Name } else { missing $row.Name } + } + + if (Get-JsonMember $settings 'WslUpdateRequired') { + warn 'Docker reports that WSL needs updating, which upgrade-host.ps1 -Wsl does once Docker is quit' + } + + log '' + info 'Change this in Docker Desktop under Settings, Resources, WSL integration.' + info 'It is reported here and never written, because Docker rewrites its settings file from memory while it runs.' +} + +function Show-Status { + if (-not (Test-WslPresent)) { + die 'wsl.exe not found, so WSL is not installed on this host. Install it with: wsl --install --no-distribution' + } + Show-Platform + $distribution = Show-Distribution + Show-Integration -Distribution $distribution +} + +function Show-List { + if (-not (Test-WslPresent)) { + die 'wsl.exe not found, so WSL is not installed on this host. Install it with: wsl --install --no-distribution' + } + $rows = Get-WslAvailable + if ($rows.Count -eq 0) { + die 'WSL listed no installable distributions, which usually means it could not reach the distribution index' + } + $format = '{0,-32} {1}' + log ($format -f 'NAME', 'FRIENDLY NAME') + foreach ($row in $rows) { log ($format -f $row.Name, $row.Friendly) } +} + +function Install-Distribution { + if (-not (Test-WslPresent)) { + die 'wsl.exe not found, so WSL is not installed on this host. Install it with: wsl --install --no-distribution' + } + if ($script:WANT_NAMES.Count -ne 1) { + die 'Name one distribution to install, and -List names the ones this host can install' + } + $wanted = $script:WANT_NAMES[0] + + # Checked against the index rather than left to wsl, whose failure for an unknown name is a long help text rather than a message naming the problem. + $available = Get-WslAvailable + if ($available.Count -gt 0 -and ($available | ForEach-Object { $_.Name }) -notcontains $wanted) { + die "`"$wanted`" is not a distribution this host can install, and -List names the ones it can" + } + + $installed = Get-WslDistribution + if (($installed | ForEach-Object { $_.Name }) -contains $wanted) { + log "$wanted is already installed, leaving it alone" + return + } + + # The name is wrapped because a question mark is a legal character in a variable name, so "$wanted?" reads as a variable nobody set. + if (-not (confirm "Install $($wanted)?")) { die 'Declined' } + + step "Installing $wanted" + # The first run account setup is skipped, which is what makes this safe unattended, and it leaves the distribution with no user until somebody launches it. + $code = run -Command 'wsl.exe' -Arguments '--install', $wanted, '--no-launch' + if ($code -ne 0) { die "wsl --install exited $code" } + + if ($script:WANT_DEFAULT) { + step "Making $wanted the default distribution" + $code = run -Command 'wsl.exe' -Arguments '--set-default', $wanted + if ($code -ne 0) { warn "wsl --set-default exited $code" } + } + + step 'Done' + info "Launch it once with `"wsl -d $wanted`" to create its user account, which this deliberately did not do" + info 'Enable it for Docker in Docker Desktop under Settings, Resources, WSL integration' +} + +# --- Entry --- + +# PowerShell records which switches were given and not the order they came in, so two actions is a refusal rather than the last one winning. +# Refusing is also the better answer: an action silently discarded is one the caller believes ran. +function Resolve-Mode { + $given = @($script:ACTIONS.Keys | Where-Object { $script:ACTIONS[$_] }) + if ($given.Count -gt 1) { die "More than one action given ($($given -join ', ')), name one" } + if ($given.Count -eq 0) { return 'status' } + return $given[0] +} + +function main { + if ($script:WANT_HELP) { usage; exit 0 } + $script:MODE = Resolve-Mode + + switch ($script:MODE) { + 'list' { Show-List } + 'install' { Install-Distribution } + default { Show-Status } + } +} + +main diff --git a/host-setup/windows/upgrade-host.ps1 b/host-setup/windows/upgrade-host.ps1 index 3e8e0e2f..7e51798b 100644 --- a/host-setup/windows/upgrade-host.ps1 +++ b/host-setup/windows/upgrade-host.ps1 @@ -1,342 +1,342 @@ -# Upgrades this host, on native Windows. -# Two things are brought current: the packages winget manages, which is routine, and the WSL platform itself, which is the Windows peer of a kernel and moves on its own release schedule. -# -# A WSL platform update is refused while Docker Desktop is running, because Docker holds the WSL service open and the update then fails part way rather than declining. -# Refusing is the point of running this rather than the two commands by hand: each is one line, and the guard between them is not. -# -# There is no action for a Windows feature update, where the Linux peer moves a host to the next release. -# Windows Update owns that upgrade, it is not driven the way an apt sources rewrite is, and an action that pretended otherwise would be the one thing this script must not carry. - -[CmdletBinding()] -param( - [Alias('s')][switch]$Status, - [Alias('p')][switch]$Packages, - [Alias('w')][switch]$Wsl, - [Alias('a')][switch]$All, - [Alias('n')][switch]$DryRun, - [Alias('y')][switch]$Yes, - [Alias('h')][switch]$Help -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' -# A non-zero exit from winget or wsl is an answer here rather than a failure. -# Setting this keeps a profile that turned it on from turning every read into a terminating error. -$PSNativeCommandUseErrorActionPreference = $false - -# The processes that mean Docker Desktop is up, and the reason each is here rather than the obvious alternative, is in Assert-DockerStopped. -$DOCKER_PROCESSES = @('Docker Desktop', 'com.docker.backend', 'com.docker.build') - -# Where a package that winget will not move is actually upgraded from. -# One line per package rather than a rule, because the answer is the application's own and nothing about it can be worked out from winget. -$SELF_UPDATE_NOTE = @{ - 'MSYS2.MSYS2' = 'Upgraded from inside msys2 with pacman, not from here.' -} - -# Every parameter is read into a variable here rather than from inside a function. -# A script parameter reached only from a nested scope reads as declared and never used, which is a finding on each one and hides a parameter that genuinely is unused. -$ACTIONS = [ordered]@{ - status = [bool]$Status - packages = [bool]$Packages - wsl = [bool]$Wsl - all = [bool]$All -} -$WANT_HELP = [bool]$Help - -$MODE = 'packages' -$DRY_RUN = [bool]$DryRun -$ASSUME_YES = [bool]$Yes -$ELEVATED = $false - -# --- Output --- - -function log { param([string]$Message = '') Write-Host $Message } -function info { param([string]$Message) Write-Host " $Message" } -function step { param([string]$Message) Write-Host "`n==> $Message" } -function warn { param([string]$Message) [Console]::Error.WriteLine("WARNING: $Message") } -function die { param([string]$Message) [Console]::Error.WriteLine("ERROR: $Message"); exit 1 } - -function usage { - # The closing marker of a here-string has to sit at column 0, so this block is deliberately unindented. - Write-Host @' -Usage: upgrade-host.ps1 [options] - -Upgrades this host: the packages winget manages, and the WSL platform. A Windows feature update is -Windows Update's to make and has no action here. - -Actions, name one, default -Packages: - -s, -Status Report the host, what is upgradable, and the WSL platform - -p, -Packages Upgrade every winget package that has an upgrade - -w, -Wsl Update the WSL platform only - -a, -All Upgrade the packages, then update the WSL platform - -h, -Help Show this help - -Options: - -n, -DryRun Print the commands instead of running them - -y, -Yes Do not prompt before changing the host - -A WSL platform update stops the WSL service, and Docker Desktop holds it open, so this refuses to -start one while Docker is running. Quit Docker from its tray icon first, since pausing it is not -enough. Updating WSL also restarts every distribution, so anything running inside one is stopped. - -Some packages report a version winget cannot move, because the application updates itself and the -version it was installed at is the one recorded. Those are listed apart and left alone, and the -report names where each is actually upgraded from. - -Examples: - upgrade-host.ps1 Upgrade the winget packages - upgrade-host.ps1 -Status Report, change nothing - upgrade-host.ps1 -Packages -Yes Upgrade unattended - upgrade-host.ps1 -All Upgrade the packages, then the WSL platform - upgrade-host.ps1 -Wsl -DryRun Show what a WSL update would run -'@ -} - -# --- Host --- - -function Test-HostSupported { - if ($PSVersionTable.PSVersion.Major -lt 7) { - die "This script needs PowerShell 7 or later, and this is $($PSVersionTable.PSVersion). Install it with: winget install --id Microsoft.PowerShell --exact --source winget" - } - if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { - die 'winget not found, and this script upgrades winget packages. Install App Installer from the Microsoft Store, then run this again.' - } - $identity = [Security.Principal.WindowsIdentity]::GetCurrent() - $script:ELEVATED = ([Security.Principal.WindowsPrincipal]$identity).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) -} - -function Get-HostDescription { - $caption = (Get-CimInstance Win32_OperatingSystem).Caption - $wingetVersion = (& winget --version 2>&1 | Out-String).Trim() - return "$caption $([Environment]::OSVersion.Version), pwsh $($PSVersionTable.PSVersion), winget $wingetVersion" -} - -# --- Execution --- - -# Run a command, or print it under -DryRun. -# A read used to decide what to do runs either way, and only a command that changes the host goes through here. -function run { - param([Parameter(Mandatory)][string]$Command, [Parameter(ValueFromRemainingArguments)][string[]]$Arguments) - if ($script:DRY_RUN) { - Write-Host " [dry run] $Command $($Arguments -join ' ')" - return 0 - } - # The command's own output goes to the console rather than down the pipeline. - # A native command writes to this function's output stream, so without this the caller receives every line the command printed with the exit code appended, and a check against 0 then compares against the first line of output. - & $Command @Arguments | Out-Host - return $LASTEXITCODE -} - -function confirm { - param([Parameter(Mandatory)][string]$Question) - if ($script:ASSUME_YES -or $script:DRY_RUN) { return $true } - # Both are checked because a scheduled task reports one and not the other, and either alone misses a case. - if (-not [Environment]::UserInteractive -or [Console]::IsInputRedirected) { - die 'Not a terminal and -Yes was not given, refusing to change the host unattended' - } - return ((Read-Host "$Question [y/N]") -match '^(y|yes)$') -} - -# --- winget --- - -# What winget would upgrade, split into the packages it will move and the ones it will not. -# The two halves are counted here rather than read off winget's own closing count, which sums both and so reports a number that matches neither list. -# Each row is parsed by header offset, since a package name carries spaces and splitting on them moves the version into the name. -function Get-Upgradable { - $result = @{ Ready = @(); Explicit = @() } - $text = (& winget upgrade --include-unknown --disable-interactivity 2>&1 | Out-String -Width 500) - if ($LASTEXITCODE -ne 0) { return $result } - - $marker = 'require explicit targeting for upgrade' - $index = $text.IndexOf($marker) - $ready = if ($index -ge 0) { $text.Substring(0, $index) } else { $text } - $explicit = if ($index -ge 0) { $text.Substring($index) } else { '' } - - $result.Ready = Read-UpgradeTable -Text $ready - $result.Explicit = Read-UpgradeTable -Text $explicit - return $result -} - -function Read-UpgradeTable { - param([string]$Text) - $rows = @() - if (-not $Text) { return , $rows } - $lines = $Text -split "`r?`n" - $header = $lines | Where-Object { $_ -match '^Name\s+Id\s+Version\s+Available' } | Select-Object -First 1 - if (-not $header) { return , $rows } - $idColumn = $header.IndexOf('Id') - $versionColumn = $header.IndexOf('Version') - $availableColumn = $header.IndexOf('Available') - foreach ($line in $lines) { - if ($line.Length -le $availableColumn) { continue } - if ($line -match '^Name\s+Id\s+Version') { continue } - if ($line -match '^-+$') { continue } - $id = ($line.Substring($idColumn, $versionColumn - $idColumn)).Trim() - # A row whose id column is blank is wrapped output or a progress line rather than a package. - if (-not $id -or $id -notmatch '^\S+$') { continue } - $rows += @{ - Id = $id - Version = ($line.Substring($versionColumn, $availableColumn - $versionColumn)).Trim() - Available = (($line.Substring($availableColumn) -split '\s+')[0]).Trim() - } - } - return , $rows -} - -# --- WSL --- - -# Every wsl.exe call goes through here, because wsl.exe emits UTF-16 by default and its output then reads as NUL separated characters. -# WSL_UTF8 changes what wsl.exe emits, where setting the console encoding would only change how this process decodes it and would corrupt in-distribution output that is already UTF-8. -# A host whose WSL predates WSL_UTF8 still answers in UTF-16, so a result carrying a NUL is stripped rather than reported as unreadable. -function Invoke-Wsl { - param([Parameter(ValueFromRemainingArguments)][string[]]$Arguments) - $previous = $env:WSL_UTF8 - try { - $env:WSL_UTF8 = '1' - $text = (& wsl.exe @Arguments 2>&1 | Out-String -Width 500) - if ($text.Contains([char]0)) { $text = $text -replace "`0", '' } - return $text - } finally { - if ($null -eq $previous) { Remove-Item Env:\WSL_UTF8 -ErrorAction SilentlyContinue } - else { $env:WSL_UTF8 = $previous } - } -} - -function Get-WslVersion { - if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { return $null } - $text = Invoke-Wsl '--version' - if ($LASTEXITCODE -ne 0) { return $null } - $parts = @() - foreach ($label in 'WSL version', 'Kernel version', 'WSLg version') { - if ($text -match "(?m)^$label`:\s*(\S+)\s*$") { $parts += "$label $($Matches[1])" } - } - if ($parts.Count -eq 0) { return $null } - return ($parts -join ', ') -} - -# --- Docker --- - -# Whether Docker Desktop is running, which is what blocks a WSL platform update. -# The service is not the probe: com.docker.service reads Stopped on a host with Docker Desktop plainly running, since it is the elevated helper rather than the engine. -# Neither wslservice nor vmmemWSL is the probe either, since both run for any distribution and would report Docker on a host that has none. -function Get-DockerProcess { - return , @(Get-Process -Name $script:DOCKER_PROCESSES -ErrorAction SilentlyContinue) -} - -# A WSL platform update stops and restarts the WSL service, and Docker Desktop holds it open, so the update fails part way rather than declining. -# The guard runs under -DryRun too, since it is a read that decides what to do rather than a change to the host, and a dry run that printed the command would say the update was available when it is not. -function Assert-DockerStopped { - $running = Get-DockerProcess - if ($running.Count -eq 0) { return } - info "Running: $((($running | ForEach-Object { $_.Name }) | Sort-Object -Unique) -join ', ')" - die 'Docker Desktop is running, and a WSL platform update fails part way while it holds the WSL service open. Quit Docker Desktop from its tray icon and wait for it to report that it has stopped, then run this again. Pausing it is not enough.' -} - -# --- Actions --- - -function Show-Status { - log "Host : $(Get-HostDescription)" - if ($script:ELEVATED) { - log 'Elevation : elevated, and an unelevated run is the one to prefer' - } else { - log 'Elevation : not elevated, which is the state to prefer' - } - - $upgradable = Get-Upgradable - log "Upgradable: $($upgradable.Ready.Count) package(s)" - if ($upgradable.Explicit.Count -gt 0) { - log "Self-updating: $($upgradable.Explicit.Count) package(s), which winget does not move" - } - - $wslVersion = Get-WslVersion - log "WSL : $(if ($wslVersion) { $wslVersion } else { 'not installed, or its version could not be read' })" - - $docker = Get-DockerProcess - if ($docker.Count -gt 0) { - $names = (($docker | ForEach-Object { $_.Name }) | Sort-Object -Unique) -join ', ' - log "Docker : running ($names), so a WSL platform update is refused" - } else { - log 'Docker : not running' - } - - if ($upgradable.Explicit.Count -eq 0) { return } - log '' - log 'Self-updating, so winget reports the version it was installed at rather than the version it runs:' - foreach ($row in $upgradable.Explicit) { - info "$($row.Id) installed at $($row.Version), source carries $($row.Available)" - # No command is printed for these, deliberately: winget cannot move them, and offering one invites a full reinstall over a working copy in pursuit of a number that does not change. - if ($script:SELF_UPDATE_NOTE.ContainsKey($row.Id)) { - info " $($script:SELF_UPDATE_NOTE[$row.Id])" - } else { - info ' This application updates itself, so upgrade it from its own tooling.' - } - } -} - -function Invoke-PackageUpgrade { - $upgradable = Get-Upgradable - if ($upgradable.Ready.Count -eq 0) { - step 'Upgrading winget packages' - info 'Nothing to upgrade' - } else { - step "Upgrading $($upgradable.Ready.Count) winget package(s)" - foreach ($row in $upgradable.Ready) { info "$($row.Id) $($row.Version) -> $($row.Available)" } - $code = run -Command 'winget' -Arguments 'upgrade', '--all', '--include-unknown', '--disable-interactivity', - '--accept-source-agreements', '--accept-package-agreements', '--silent' - if ($code -ne 0) { warn "winget exited $code, so one or more packages did not upgrade" } - } - - # Named rather than silently skipped, because a package winget leaves alone reads as one it upgraded. - if ($upgradable.Explicit.Count -gt 0) { - step "Left alone, $($upgradable.Explicit.Count) package(s) that update themselves" - foreach ($row in $upgradable.Explicit) { info $row.Id } - } -} - -function Invoke-WslUpdate { - step 'Updating the WSL platform' - if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { - warn 'wsl.exe not found, so there is no WSL platform to update' - return - } - Assert-DockerStopped - info 'This restarts every distribution, so anything running inside one is stopped' - $code = run -Command 'wsl.exe' -Arguments '--update' - if ($code -ne 0) { warn "wsl --update exited $code" } -} - -function Invoke-Upgrade { - log "Host: $(Get-HostDescription)" - log "Mode: $($script:MODE)$(if ($script:DRY_RUN) { ' (dry run)' })" - - # The guard runs before the prompt, so a run that cannot finish says so rather than asking first and refusing after. - if ($script:MODE -in @('wsl', 'all')) { Assert-DockerStopped } - - if (-not (confirm 'Upgrade this host?')) { die 'Declined' } - - if ($script:MODE -in @('packages', 'all')) { Invoke-PackageUpgrade } - if ($script:MODE -in @('wsl', 'all')) { Invoke-WslUpdate } - - step 'Done' -} - -# --- Entry --- - -# PowerShell records which switches were given and not the order they came in, so two actions is a refusal rather than the last one winning. -# Refusing is also the better answer: an action silently discarded is one the caller believes ran. -function Resolve-Mode { - $given = @($script:ACTIONS.Keys | Where-Object { $script:ACTIONS[$_] }) - if ($given.Count -gt 1) { die "More than one action given ($($given -join ', ')), name one" } - if ($given.Count -eq 0) { return 'packages' } - return $given[0] -} - -function main { - if ($script:WANT_HELP) { usage; exit 0 } - $script:MODE = Resolve-Mode - Test-HostSupported - - if ($script:MODE -eq 'status') { Show-Status } else { Invoke-Upgrade } -} - -main +# Upgrades this host, on native Windows. +# Two things are brought current: the packages winget manages, which is routine, and the WSL platform itself, which is the Windows peer of a kernel and moves on its own release schedule. +# +# A WSL platform update is refused while Docker Desktop is running, because Docker holds the WSL service open and the update then fails part way rather than declining. +# Refusing is the point of running this rather than the two commands by hand: each is one line, and the guard between them is not. +# +# There is no action for a Windows feature update, where the Linux peer moves a host to the next release. +# Windows Update owns that upgrade, it is not driven the way an apt sources rewrite is, and an action that pretended otherwise would be the one thing this script must not carry. + +[CmdletBinding()] +param( + [Alias('s')][switch]$Status, + [Alias('p')][switch]$Packages, + [Alias('w')][switch]$Wsl, + [Alias('a')][switch]$All, + [Alias('n')][switch]$DryRun, + [Alias('y')][switch]$Yes, + [Alias('h')][switch]$Help +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +# A non-zero exit from winget or wsl is an answer here rather than a failure. +# Setting this keeps a profile that turned it on from turning every read into a terminating error. +$PSNativeCommandUseErrorActionPreference = $false + +# The processes that mean Docker Desktop is up, and the reason each is here rather than the obvious alternative, is in Assert-DockerStopped. +$DOCKER_PROCESSES = @('Docker Desktop', 'com.docker.backend', 'com.docker.build') + +# Where a package that winget will not move is actually upgraded from. +# One line per package rather than a rule, because the answer is the application's own and nothing about it can be worked out from winget. +$SELF_UPDATE_NOTE = @{ + 'MSYS2.MSYS2' = 'Upgraded from inside msys2 with pacman, not from here.' +} + +# Every parameter is read into a variable here rather than from inside a function. +# A script parameter reached only from a nested scope reads as declared and never used, which is a finding on each one and hides a parameter that genuinely is unused. +$ACTIONS = [ordered]@{ + status = [bool]$Status + packages = [bool]$Packages + wsl = [bool]$Wsl + all = [bool]$All +} +$WANT_HELP = [bool]$Help + +$MODE = 'packages' +$DRY_RUN = [bool]$DryRun +$ASSUME_YES = [bool]$Yes +$ELEVATED = $false + +# --- Output --- + +function log { param([string]$Message = '') Write-Host $Message } +function info { param([string]$Message) Write-Host " $Message" } +function step { param([string]$Message) Write-Host "`n==> $Message" } +function warn { param([string]$Message) [Console]::Error.WriteLine("WARNING: $Message") } +function die { param([string]$Message) [Console]::Error.WriteLine("ERROR: $Message"); exit 1 } + +function usage { + # The closing marker of a here-string has to sit at column 0, so this block is deliberately unindented. + Write-Host @' +Usage: upgrade-host.ps1 [options] + +Upgrades this host: the packages winget manages, and the WSL platform. A Windows feature update is +Windows Update's to make and has no action here. + +Actions, name one, default -Packages: + -s, -Status Report the host, what is upgradable, and the WSL platform + -p, -Packages Upgrade every winget package that has an upgrade + -w, -Wsl Update the WSL platform only + -a, -All Upgrade the packages, then update the WSL platform + -h, -Help Show this help + +Options: + -n, -DryRun Print the commands instead of running them + -y, -Yes Do not prompt before changing the host + +A WSL platform update stops the WSL service, and Docker Desktop holds it open, so this refuses to +start one while Docker is running. Quit Docker from its tray icon first, since pausing it is not +enough. Updating WSL also restarts every distribution, so anything running inside one is stopped. + +Some packages report a version winget cannot move, because the application updates itself and the +version it was installed at is the one recorded. Those are listed apart and left alone, and the +report names where each is actually upgraded from. + +Examples: + upgrade-host.ps1 Upgrade the winget packages + upgrade-host.ps1 -Status Report, change nothing + upgrade-host.ps1 -Packages -Yes Upgrade unattended + upgrade-host.ps1 -All Upgrade the packages, then the WSL platform + upgrade-host.ps1 -Wsl -DryRun Show what a WSL update would run +'@ +} + +# --- Host --- + +function Test-HostSupported { + if ($PSVersionTable.PSVersion.Major -lt 7) { + die "This script needs PowerShell 7 or later, and this is $($PSVersionTable.PSVersion). Install it with: winget install --id Microsoft.PowerShell --exact --source winget" + } + if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { + die 'winget not found, and this script upgrades winget packages. Install App Installer from the Microsoft Store, then run this again.' + } + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $script:ELEVATED = ([Security.Principal.WindowsPrincipal]$identity).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Get-HostDescription { + $caption = (Get-CimInstance Win32_OperatingSystem).Caption + $wingetVersion = (& winget --version 2>&1 | Out-String).Trim() + return "$caption $([Environment]::OSVersion.Version), pwsh $($PSVersionTable.PSVersion), winget $wingetVersion" +} + +# --- Execution --- + +# Run a command, or print it under -DryRun. +# A read used to decide what to do runs either way, and only a command that changes the host goes through here. +function run { + param([Parameter(Mandatory)][string]$Command, [Parameter(ValueFromRemainingArguments)][string[]]$Arguments) + if ($script:DRY_RUN) { + Write-Host " [dry run] $Command $($Arguments -join ' ')" + return 0 + } + # The command's own output goes to the console rather than down the pipeline. + # A native command writes to this function's output stream, so without this the caller receives every line the command printed with the exit code appended, and a check against 0 then compares against the first line of output. + & $Command @Arguments | Out-Host + return $LASTEXITCODE +} + +function confirm { + param([Parameter(Mandatory)][string]$Question) + if ($script:ASSUME_YES -or $script:DRY_RUN) { return $true } + # Both are checked because a scheduled task reports one and not the other, and either alone misses a case. + if (-not [Environment]::UserInteractive -or [Console]::IsInputRedirected) { + die 'Not a terminal and -Yes was not given, refusing to change the host unattended' + } + return ((Read-Host "$Question [y/N]") -match '^(y|yes)$') +} + +# --- winget --- + +# What winget would upgrade, split into the packages it will move and the ones it will not. +# The two halves are counted here rather than read off winget's own closing count, which sums both and so reports a number that matches neither list. +# Each row is parsed by header offset, since a package name carries spaces and splitting on them moves the version into the name. +function Get-Upgradable { + $result = @{ Ready = @(); Explicit = @() } + $text = (& winget upgrade --include-unknown --disable-interactivity 2>&1 | Out-String -Width 500) + if ($LASTEXITCODE -ne 0) { return $result } + + $marker = 'require explicit targeting for upgrade' + $index = $text.IndexOf($marker) + $ready = if ($index -ge 0) { $text.Substring(0, $index) } else { $text } + $explicit = if ($index -ge 0) { $text.Substring($index) } else { '' } + + $result.Ready = Read-UpgradeTable -Text $ready + $result.Explicit = Read-UpgradeTable -Text $explicit + return $result +} + +function Read-UpgradeTable { + param([string]$Text) + $rows = @() + if (-not $Text) { return , $rows } + $lines = $Text -split "`r?`n" + $header = $lines | Where-Object { $_ -match '^Name\s+Id\s+Version\s+Available' } | Select-Object -First 1 + if (-not $header) { return , $rows } + $idColumn = $header.IndexOf('Id') + $versionColumn = $header.IndexOf('Version') + $availableColumn = $header.IndexOf('Available') + foreach ($line in $lines) { + if ($line.Length -le $availableColumn) { continue } + if ($line -match '^Name\s+Id\s+Version') { continue } + if ($line -match '^-+$') { continue } + $id = ($line.Substring($idColumn, $versionColumn - $idColumn)).Trim() + # A row whose id column is blank is wrapped output or a progress line rather than a package. + if (-not $id -or $id -notmatch '^\S+$') { continue } + $rows += @{ + Id = $id + Version = ($line.Substring($versionColumn, $availableColumn - $versionColumn)).Trim() + Available = (($line.Substring($availableColumn) -split '\s+')[0]).Trim() + } + } + return , $rows +} + +# --- WSL --- + +# Every wsl.exe call goes through here, because wsl.exe emits UTF-16 by default and its output then reads as NUL separated characters. +# WSL_UTF8 changes what wsl.exe emits, where setting the console encoding would only change how this process decodes it and would corrupt in-distribution output that is already UTF-8. +# A host whose WSL predates WSL_UTF8 still answers in UTF-16, so a result carrying a NUL is stripped rather than reported as unreadable. +function Invoke-Wsl { + param([Parameter(ValueFromRemainingArguments)][string[]]$Arguments) + $previous = $env:WSL_UTF8 + try { + $env:WSL_UTF8 = '1' + $text = (& wsl.exe @Arguments 2>&1 | Out-String -Width 500) + if ($text.Contains([char]0)) { $text = $text -replace "`0", '' } + return $text + } finally { + if ($null -eq $previous) { Remove-Item Env:\WSL_UTF8 -ErrorAction SilentlyContinue } + else { $env:WSL_UTF8 = $previous } + } +} + +function Get-WslVersion { + if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { return $null } + $text = Invoke-Wsl '--version' + if ($LASTEXITCODE -ne 0) { return $null } + $parts = @() + foreach ($label in 'WSL version', 'Kernel version', 'WSLg version') { + if ($text -match "(?m)^$label`:\s*(\S+)\s*$") { $parts += "$label $($Matches[1])" } + } + if ($parts.Count -eq 0) { return $null } + return ($parts -join ', ') +} + +# --- Docker --- + +# Whether Docker Desktop is running, which is what blocks a WSL platform update. +# The service is not the probe: com.docker.service reads Stopped on a host with Docker Desktop plainly running, since it is the elevated helper rather than the engine. +# Neither wslservice nor vmmemWSL is the probe either, since both run for any distribution and would report Docker on a host that has none. +function Get-DockerProcess { + return , @(Get-Process -Name $script:DOCKER_PROCESSES -ErrorAction SilentlyContinue) +} + +# A WSL platform update stops and restarts the WSL service, and Docker Desktop holds it open, so the update fails part way rather than declining. +# The guard runs under -DryRun too, since it is a read that decides what to do rather than a change to the host, and a dry run that printed the command would say the update was available when it is not. +function Assert-DockerStopped { + $running = Get-DockerProcess + if ($running.Count -eq 0) { return } + info "Running: $((($running | ForEach-Object { $_.Name }) | Sort-Object -Unique) -join ', ')" + die 'Docker Desktop is running, and a WSL platform update fails part way while it holds the WSL service open. Quit Docker Desktop from its tray icon and wait for it to report that it has stopped, then run this again. Pausing it is not enough.' +} + +# --- Actions --- + +function Show-Status { + log "Host : $(Get-HostDescription)" + if ($script:ELEVATED) { + log 'Elevation : elevated, and an unelevated run is the one to prefer' + } else { + log 'Elevation : not elevated, which is the state to prefer' + } + + $upgradable = Get-Upgradable + log "Upgradable: $($upgradable.Ready.Count) package(s)" + if ($upgradable.Explicit.Count -gt 0) { + log "Self-updating: $($upgradable.Explicit.Count) package(s), which winget does not move" + } + + $wslVersion = Get-WslVersion + log "WSL : $(if ($wslVersion) { $wslVersion } else { 'not installed, or its version could not be read' })" + + $docker = Get-DockerProcess + if ($docker.Count -gt 0) { + $names = (($docker | ForEach-Object { $_.Name }) | Sort-Object -Unique) -join ', ' + log "Docker : running ($names), so a WSL platform update is refused" + } else { + log 'Docker : not running' + } + + if ($upgradable.Explicit.Count -eq 0) { return } + log '' + log 'Self-updating, so winget reports the version it was installed at rather than the version it runs:' + foreach ($row in $upgradable.Explicit) { + info "$($row.Id) installed at $($row.Version), source carries $($row.Available)" + # No command is printed for these, deliberately: winget cannot move them, and offering one invites a full reinstall over a working copy in pursuit of a number that does not change. + if ($script:SELF_UPDATE_NOTE.ContainsKey($row.Id)) { + info " $($script:SELF_UPDATE_NOTE[$row.Id])" + } else { + info ' This application updates itself, so upgrade it from its own tooling.' + } + } +} + +function Invoke-PackageUpgrade { + $upgradable = Get-Upgradable + if ($upgradable.Ready.Count -eq 0) { + step 'Upgrading winget packages' + info 'Nothing to upgrade' + } else { + step "Upgrading $($upgradable.Ready.Count) winget package(s)" + foreach ($row in $upgradable.Ready) { info "$($row.Id) $($row.Version) -> $($row.Available)" } + $code = run -Command 'winget' -Arguments 'upgrade', '--all', '--include-unknown', '--disable-interactivity', + '--accept-source-agreements', '--accept-package-agreements', '--silent' + if ($code -ne 0) { warn "winget exited $code, so one or more packages did not upgrade" } + } + + # Named rather than silently skipped, because a package winget leaves alone reads as one it upgraded. + if ($upgradable.Explicit.Count -gt 0) { + step "Left alone, $($upgradable.Explicit.Count) package(s) that update themselves" + foreach ($row in $upgradable.Explicit) { info $row.Id } + } +} + +function Invoke-WslUpdate { + step 'Updating the WSL platform' + if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { + warn 'wsl.exe not found, so there is no WSL platform to update' + return + } + Assert-DockerStopped + info 'This restarts every distribution, so anything running inside one is stopped' + $code = run -Command 'wsl.exe' -Arguments '--update' + if ($code -ne 0) { warn "wsl --update exited $code" } +} + +function Invoke-Upgrade { + log "Host: $(Get-HostDescription)" + log "Mode: $($script:MODE)$(if ($script:DRY_RUN) { ' (dry run)' })" + + # The guard runs before the prompt, so a run that cannot finish says so rather than asking first and refusing after. + if ($script:MODE -in @('wsl', 'all')) { Assert-DockerStopped } + + if (-not (confirm 'Upgrade this host?')) { die 'Declined' } + + if ($script:MODE -in @('packages', 'all')) { Invoke-PackageUpgrade } + if ($script:MODE -in @('wsl', 'all')) { Invoke-WslUpdate } + + step 'Done' +} + +# --- Entry --- + +# PowerShell records which switches were given and not the order they came in, so two actions is a refusal rather than the last one winning. +# Refusing is also the better answer: an action silently discarded is one the caller believes ran. +function Resolve-Mode { + $given = @($script:ACTIONS.Keys | Where-Object { $script:ACTIONS[$_] }) + if ($given.Count -gt 1) { die "More than one action given ($($given -join ', ')), name one" } + if ($given.Count -eq 0) { return 'packages' } + return $given[0] +} + +function main { + if ($script:WANT_HELP) { usage; exit 0 } + $script:MODE = Resolve-Mode + Test-HostSupported + + if ($script:MODE -eq 'status') { Show-Status } else { Invoke-Upgrade } +} + +main diff --git a/host-tools.json b/host-tools.json index 6e8177f9..9cea46f6 100644 --- a/host-tools.json +++ b/host-tools.json @@ -1,5 +1,5 @@ -{ - "$schema": "./spec/host-tools-local.schema.json", - "note": "This repository's own host-tool declaration, layered over the fleet declaration in spec/host-tools.json by scripts/host_gate.py. The two are not the same file, do not hold the same thing, and do not share a schema: an overlay allows an empty tools list and a partial entry, and the fleet declaration requires at least one entry and every field of each. The fleet declaration states what every repository's procedures need and is the hub's to change. This one states what this repository needs beyond that, so it is where a tool only this repository uses, or a floor only this repository requires, is declared. Layering is tighten-only: an entry here may add a tool, raise a floor, or turn an optional tool required, and may not lower a floor or turn a required tool optional, because that would retire a fleet check from inside the repository it protects. A rejected relaxation is reported rather than dropped. The tools list is empty because this repository needs no tool the fleet declaration does not already carry, and the file is still present rather than absent, on the same footing as OPERATIONS.md: a repository with nothing to add carries the stub, so the declaration is somewhere a reader can find rather than somewhere they have to know to look. A repository copying this file leaves the $schema pointer behind, because the schemas are hub-only and no selector carries one, so a relative pointer resolves to a path that repository does not have and a schema-aware editor reports the file invalid for a reason nobody there can fix. The carried spec/secrets.json already works that way, holding the pointer here and omitting it in every downstream copy.", - "tools": [] -} +{ + "$schema": "./spec/host-tools-local.schema.json", + "note": "This repository's own host-tool declaration, layered over the fleet declaration in spec/host-tools.json by scripts/host_gate.py. The two are not the same file, do not hold the same thing, and do not share a schema: an overlay allows an empty tools list and a partial entry, and the fleet declaration requires at least one entry and every field of each. The fleet declaration states what every repository's procedures need and is the hub's to change. This one states what this repository needs beyond that, so it is where a tool only this repository uses, or a floor only this repository requires, is declared. Layering is tighten-only: an entry here may add a tool, raise a floor, or turn an optional tool required, and may not lower a floor or turn a required tool optional, because that would retire a fleet check from inside the repository it protects. A rejected relaxation is reported rather than dropped. The tools list is empty because this repository needs no tool the fleet declaration does not already carry, and the file is still present rather than absent, on the same footing as OPERATIONS.md: a repository with nothing to add carries the stub, so the declaration is somewhere a reader can find rather than somewhere they have to know to look. A repository copying this file leaves the $schema pointer behind, because the schemas are hub-only and no selector carries one, so a relative pointer resolves to a path that repository does not have and a schema-aware editor reports the file invalid for a reason nobody there can fix. The carried spec/secrets.json already works that way, holding the pointer here and omitting it in every downstream copy.", + "tools": [] +} diff --git a/pyproject.toml b/pyproject.toml index 8b237b93..070898cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,18 +1,18 @@ -# Config only (no [project]/[build-system]/uv.lock) - the Scripts profile, CODESTYLE.md "Two profiles". - -[tool.ruff] -target-version = "py313" -line-length = 100 - -[tool.ruff.lint] -extend-select = ["I"] # isort import ordering, on top of the default rules - -[tool.mypy] -python_version = "3.13" -files = ["spec", "host-setup", "scripts"] - -[tool.pyright] -pythonVersion = "3.13" -typeCheckingMode = "standard" -include = ["spec", "host-setup", "scripts"] -exclude = ["**/__pycache__"] +# Config only (no [project]/[build-system]/uv.lock) - the Scripts profile, CODESTYLE.md "Two profiles". + +[tool.ruff] +target-version = "py313" +line-length = 100 + +[tool.ruff.lint] +extend-select = ["I"] # isort import ordering, on top of the default rules + +[tool.mypy] +python_version = "3.13" +files = ["spec", "host-setup", "scripts"] + +[tool.pyright] +pythonVersion = "3.13" +typeCheckingMode = "standard" +include = ["spec", "host-setup", "scripts"] +exclude = ["**/__pycache__"] diff --git a/registry/repos.json b/registry/repos.json index d93c4fd9..1e3667e1 100644 --- a/registry/repos.json +++ b/registry/repos.json @@ -1,325 +1,325 @@ -{ - "$schema": "./repos.schema.json", - "owner": "ptr727", - "defaults": { - "groundTruthBranch": "main", - "releaseTrigger": "two-phase", - "workflowModel": "release" - }, - "repos": [ - { - "name": "ProjectTemplate", - "url": "https://github.com/ptr727/ProjectTemplate", - "status": "cataloged", - "types": ["source-only", "docs"], - "groundTruthBranch": "main", - "hasDevelop": true, - "publish": [{ "target": "github-release", "mechanism": "none" }], - "requiredSecrets": [], - "consumerModel": "pull", - "releaseTrigger": "two-phase", - "configLayout": { "rulesetsDir": "repo-config", "pythonConfig": null }, - "driftNotes": ["Governance hub; audits its own rules against itself."] - }, - { - "name": "Utilities", - "url": "https://github.com/ptr727/Utilities", - "status": "cataloged", - "types": ["csharp", "nuget"], - "groundTruthBranch": "main", - "hasDevelop": true, - "publish": [{ "target": "nuget", "mechanism": "oidc" }], - "requiredSecrets": ["NUGET_USERNAME", "CODECOV_TOKEN"], - "consumerModel": "pull", - "releaseTrigger": "two-phase", - "configLayout": { "rulesetsDir": "repo-config", "pythonConfig": null }, - "driftNotes": ["No get-version-task; relies on validate-task."] - }, - { - "name": "LanguageTags", - "url": "https://github.com/ptr727/LanguageTags", - "status": "cataloged", - "types": ["csharp", "nuget", "codegen"], - "groundTruthBranch": "main", - "hasDevelop": true, - "publish": [{ "target": "nuget", "mechanism": "oidc" }], - "requiredSecrets": ["NUGET_USERNAME", "CODECOV_TOKEN"], - "consumerModel": "pull", - "releaseTrigger": "two-phase", - "configLayout": { "rulesetsDir": "repo-config", "pythonConfig": null }, - "driftNotes": ["No get-version-task; relies on validate-task."] - }, - { - "name": "aiopurpleair", - "url": "https://github.com/ptr727/aiopurpleair", - "status": "cataloged", - "types": ["python", "pypi"], - "groundTruthBranch": "main", - "hasDevelop": true, - "publish": [{ "target": "pypi", "mechanism": "oidc" }], - "requiredSecrets": ["CODECOV_TOKEN"], - "consumerModel": "pull", - "releaseTrigger": "publish-on-merge", - "configLayout": { "rulesetsDir": "repo-config", "pythonConfig": "pyproject.toml" }, - "driftNotes": ["Enforces a 99%+ coverage threshold, beyond the fleet's report-only codecov default."] - }, - { - "name": "homeassistant-purpleair", - "url": "https://github.com/ptr727/homeassistant-purpleair", - "status": "cataloged", - "types": ["python", "homeassistant"], - "groundTruthBranch": "main", - "hasDevelop": true, - "publish": [{ "target": "github-release", "mechanism": "none" }], - "requiredSecrets": ["CODECOV_TOKEN"], - "consumerModel": "push", - "releaseTrigger": "dispatch-only", - "configLayout": { "rulesetsDir": "repo-config", "pythonConfig": "standalone" }, - "driftNotes": [ - "ruff/pyright in standalone .ruff.toml + pyrightconfig.json instead of pyproject.toml (canonicalize).", - "Enforces a 99%+ coverage threshold, beyond the fleet's report-only codecov default." - ] - }, - { - "name": "Financial-Modeling", - "url": "https://github.com/ptr727/Financial-Modeling", - "status": "cataloged", - "types": ["python", "source-only"], - "groundTruthBranch": "main", - "hasDevelop": true, - "publish": [], - "requiredSecrets": ["CODECOV_TOKEN"], - "consumerModel": "pull", - "releaseTrigger": "dispatch-only", - "configLayout": { "pythonConfig": "pyproject.toml" }, - "driftNotes": [ - "Personal Python toolkit (uv/pyproject, src/ + tests/ + analysis/data/docs); private.", - "Source-release repo: source-only, no PyPI - a tag plus a source zip on manual dispatch (releaseTrigger dispatch-only).", - "PR CI established (test-pull-request.yml -> validate-task.yml: ruff + mypy + pytest/coverage). Python profile: mypy is the CI type checker (pyright editor-only via Pylance), deps via PEP 621 [project.optional-dependencies], static version (no _version.py).", - "Carries the AGENTS.md router split: GOVERNANCE.md with the verbatim sections, repo-specific content extracted to OPERATIONS.md, plus .markdownlint-cli2.jsonc, CODESTYLE.md, .editorconfig, .gitattributes, WORKFLOW.md, version.json + NBGV, the dispatch publisher, and dependabot.yml." - ] - }, - { - "name": "PlexCleaner", - "url": "https://github.com/ptr727/PlexCleaner", - "status": "cataloged", - "types": ["csharp", "console", "docker", "python"], - "groundTruthBranch": "main", - "hasDevelop": true, - "publish": [ - { "target": "docker", "mechanism": "static-secret" }, - { "target": "github-release", "mechanism": "none" } - ], - "requiredSecrets": ["DOCKER_HUB_USERNAME", "DOCKER_HUB_ACCESS_TOKEN", "CODECOV_TOKEN"], - "consumerModel": "pull", - "releaseTrigger": "two-phase", - "configLayout": { "rulesetsDir": "repo-config", "pythonConfig": null }, - "driftNotes": ["Carries ARCHITECTURE.md and codecov.yml beyond the baseline.", "First csharp+python repo: a C# console app at the root plus a stdlib-only Python tooling subtree (RegressionTests/, uvx scripts profile - no uv.lock, pyproject carries only ruff+mypy config; PlexCleaner#855). python.uvlock.pinned and python.coverage.codecov are N/A for that subtree (no uv project, no tests); codecov.yml stays required for the C# side. Reference for the csharp+python shape (issue #339)."] - }, - { - "name": "ESPHome-NonRoot", - "url": "https://github.com/ptr727/ESPHome-NonRoot", - "status": "cataloged", - "types": ["docker", "upstream-wrapper"], - "groundTruthBranch": "main", - "hasDevelop": true, - "publish": [{ "target": "docker", "mechanism": "static-secret" }], - "requiredSecrets": ["DOCKER_HUB_USERNAME", "DOCKER_HUB_ACCESS_TOKEN"], - "consumerModel": "pull", - "releaseTrigger": "two-phase", - "configLayout": { "rulesetsDir": "repo-config", "pythonConfig": null }, - "driftNotes": ["Upstream-version tracker drives the image tag."] - }, - { - "name": "VSCode-Server-DotNetCore", - "url": "https://github.com/ptr727/VSCode-Server-DotNetCore", - "status": "cataloged", - "types": ["docker"], - "groundTruthBranch": "main", - "hasDevelop": true, - "publish": [{ "target": "docker", "mechanism": "static-secret" }], - "requiredSecrets": ["DOCKER_HUB_USERNAME", "DOCKER_HUB_ACCESS_TOKEN"], - "consumerModel": "pull", - "releaseTrigger": "two-phase", - "configLayout": { "rulesetsDir": "repo-config", "pythonConfig": null }, - "driftNotes": ["Separate publish-docker-readme-task for the Hub overview."] - }, - { - "name": "NxWitness", - "url": "https://github.com/ptr727/NxWitness", - "status": "cataloged", - "types": ["docker", "upstream-wrapper", "codegen", "csharp"], - "groundTruthBranch": "main", - "hasDevelop": true, - "publish": [{ "target": "docker", "mechanism": "static-secret" }, { "target": "github-release", "mechanism": "none" }], - "requiredSecrets": ["DOCKER_HUB_USERNAME", "DOCKER_HUB_ACCESS_TOKEN", "CODECOV_TOKEN"], - "consumerModel": "pull", - "releaseTrigger": "two-phase", - "driftNotes": ["Docker image wrapping upstream Nx products; C# (CreateMatrix) is the codegen generator, not a shipped package (IsPackable=false, no nuget push).", "Release is the two-phase model (weekly schedule + workflow_dispatch publish; ordinary merges do not) plus an extra Make/Matrix.json path-scoped push that republishes when the codegen version pin bumps.", "Docker Hub README published per-image via a Matrix.json-derived matrix.", "Branch hygiene: 3 stale Dependabot nuget branches (PRs closed/superseded) linger, safe to delete; main+develop otherwise clean after the 2026-07 sweep."] - }, - { - "name": "HomeAutomation-Config", - "url": "https://github.com/ptr727/HomeAutomation-Config", - "status": "cataloged", - "types": ["source-only"], - "groundTruthBranch": "main", - "workflowModel": "operational", - "lineEndings": "lf", - "hasDevelop": true, - "publish": [{ "target": "github-release", "mechanism": "none" }], - "requiredSecrets": [], - "consumerModel": "pull", - "releaseTrigger": "dispatch-only", - "driftNotes": ["Maintainer config/ops repo (docker-compose stacks, lifecycle scripts, Firewalla configs); Linux-consumed on the Proxmox host, so lineEndings lf.", "Renamed from HomeAutomation for fleet naming consistency (config repos are *-Config). The Vantage controller config is split out to its own Windows/CRLF Vantage-Config repo, not carried here; the legacy Vantage/ subtree is stripped."] - }, - { - "name": "KiCadLibrary", - "url": "https://github.com/ptr727/KiCadLibrary", - "status": "cataloged", - "types": ["eda"], - "hasDevelop": true, - "publish": [{ "target": "github-release", "mechanism": "none" }], - "requiredSecrets": [], - "consumerModel": "pull", - "releaseTrigger": "two-phase", - "driftNotes": ["EDA/KiCad part library; delivers a github-release data zip.", "main is stale (data + README only): the full fleet CI, NBGV version.json, and the Python build/verify pipeline live only on develop - promote to main to converge.", "Python tooling uses requirements-dev.txt, not pyproject.toml; no repo-config/ rulesets."] - }, - { - "name": "EspDinIoT", - "url": "https://github.com/ptr727/EspDinIoT", - "status": "cataloged", - "types": ["eda"], - "hasDevelop": true, - "publish": [], - "requiredSecrets": [], - "consumerModel": "pull", - "releaseTrigger": "none", - "driftNotes": ["EDA/KiCad PCB design; main is a stub - the real design lives on develop and feature/schematic, populate main.", "No CI on any branch; develop has partial governance adoption (AGENTS.md/.editorconfig) but no workflows."] - }, - { - "name": "ESPHome-Config", - "url": "https://github.com/ptr727/ESPHome-Config", - "status": "cataloged", - "types": ["source-only", "python", "cpp"], - "profiles": { "python": "lint-only", "cpp": "lint-only" }, - "groundTruthBranch": "main", - "workflowModel": "operational", - "lineEndings": "lf", - "hasDevelop": true, - "publish": [{ "target": "github-release", "mechanism": "none" }], - "requiredSecrets": [], - "consumerModel": "pull", - "releaseTrigger": "dispatch-only", - "driftNotes": ["ESPHome device config YAML consumed by the cataloged ESPHome-NonRoot image at runtime. Distinct from that Docker repo.", "First repo with a lint-only python codegen subtree (easystart/python: ruff and pyright config only, no tests, no uv.lock) and lint-only cpp (committed custom-component and template headers, formatted by a shared clang-format config)."] - }, - { - "name": "HomeAssistant-Config", - "url": "https://github.com/ptr727/HomeAssistant-Config", - "status": "cataloged", - "types": ["source-only"], - "groundTruthBranch": "main", - "workflowModel": "operational", - "lineEndings": "lf", - "hasDevelop": true, - "publish": [{ "target": "github-release", "mechanism": "none" }], - "requiredSecrets": [], - "consumerModel": "pull", - "releaseTrigger": "dispatch-only", - "driftNotes": ["Home Assistant CONFIGURATION (configuration.yaml + automations/blueprints), NOT a HACS integration (no custom_components/manifest.json, no hacs.json).", "Operational onboarding completed 2026-07-17 (HomeAssistant-Config #16): master->main rename + develop created, advisory lint CI (Check pull request workflow status job required check), dispatch-only source release (version.json + NBGV + publish-release.yml), repo-config operational carry (rulesets/settings applied and verified in sync), Dependabot + App merge-bot with the CODEGEN_APP_* pair in both stores, adapted self-audit (AUDIT.md + spec/secrets.json); baseline promoted develop->main via HomeAssistant-Config #17.", "groundTruthBranch intentionally main: develop is the working branch (direct signed commits), main the promoted stable snapshot the audit targets - deliberately not flipped to develop (ptr727/ProjectTemplate#340).", "Private; deployed by git pull into the HA config dir."] - }, - { - "name": "DevKitCIoT", - "url": "https://github.com/ptr727/DevKitCIoT", - "status": "cataloged", - "types": ["eda"], - "groundTruthBranch": "main", - "hasDevelop": true, - "publish": [], - "requiredSecrets": [], - "consumerModel": "pull", - "releaseTrigger": "none", - "driftNotes": ["EDA/KiCad PCB design + fabrication data (gerbers/BOM); no CI on any branch, no release pipeline.", "Depends on KiCadLibrary as an upstream part source (manual git clone).", "Branch hygiene: develop and main have diverged (not forward-only); feature/kicad10-upgrade is merged (PR#4) but retained for the in-progress KiCad 10 migration. A stale THT branch was removed in the 2026-07 sweep."] - }, - { - "name": "PhotoCleaner", - "url": "https://github.com/ptr727/PhotoCleaner", - "status": "cataloged", - "types": ["csharp", "console", "docker"], - "hasDevelop": true, - "publish": [{ "target": "docker", "mechanism": "static-secret" }, { "target": "github-release", "mechanism": "none" }], - "requiredSecrets": ["DOCKER_HUB_USERNAME", "DOCKER_HUB_ACCESS_TOKEN", "CODECOV_TOKEN"], - "consumerModel": "pull", - "releaseTrigger": "two-phase", - "driftNotes": ["Multi-arch Docker (linux/amd64,linux/arm64 on main) pushed to Docker Hub via the static DOCKER_HUB_USERNAME/DOCKER_HUB_ACCESS_TOKEN secret, plus a github-release attaching the multi-arch executables as a 7z. Release is two-phase (workflow_dispatch + weekly Mon 02:00 schedule).", "Private for now by maintainer decision, going public once the conformance work settles, since public serves prospective users and lowers the GitHub bill. Until that flip the declared github-release channel is not consumer-pullable and shields.io cannot read the repo, so the GitHub-sourced shields render broken."] - }, - { - "name": "MediaTools", - "url": "https://github.com/ptr727/MediaTools", - "status": "cataloged", - "types": ["csharp", "nuget"], - "groundTruthBranch": "main", - "hasDevelop": true, - "publish": [{ "target": "nuget", "mechanism": "oidc" }], - "requiredSecrets": ["NUGET_USERNAME", "CODECOV_TOKEN"], - "consumerModel": "pull", - "releaseTrigger": "two-phase", - "driftNotes": ["Rulesets applied from the hub canonical repo-config/, not committed in-repo (no repo-config/ directory), as with NxWitness.", "No WORKFLOW.md sibling doc (pending fleet-wide ratification, ptr727/ProjectTemplate#223); workflow comments carry the rationale inline."] - }, - { - "name": "AudioCleaner", - "url": "https://github.com/ptr727/AudioCleaner", - "status": "cataloged", - "types": ["csharp", "console"], - "groundTruthBranch": "main", - "hasDevelop": true, - "publish": [], - "requiredSecrets": ["CODECOV_TOKEN"], - "consumerModel": "pull", - "releaseTrigger": "none", - "driftNotes": ["Work-in-progress: pre-CI (no .github/workflows, no version.json, no repo-config). main+develop both exist."] - }, - { - "name": "Vantage-Config", - "url": "https://github.com/ptr727/Vantage-Config", - "status": "cataloged", - "types": ["source-only"], - "groundTruthBranch": "main", - "workflowModel": "operational", - "lineEndings": "crlf", - "hasDevelop": true, - "publish": [{ "target": "github-release", "mechanism": "none" }], - "requiredSecrets": [], - "consumerModel": "pull", - "releaseTrigger": "dispatch-only", - "driftNotes": ["Vantage InFusion / Design Center controller config edited on Windows (UTF-8 CRLF .dc XML, really special XML), so lineEndings crlf.", "Recreated lean and single-platform: Design Center is freely available, so no installer archives are kept; split out of HomeAutomation-Config.", "Operational onboarding completed 2026-07-16 (Vantage-Config #9): baseline docs, advisory lint CI, dispatch-only publisher, repo-config operational carry (rulesets/settings applied and verified in sync), Dependabot + App merge-bot with the secret pair in both stores, adapted self-audit (AUDIT.md + spec/secrets.json)."] - }, - { - "name": "HolidayLights", - "url": "https://github.com/ptr727/HolidayLights", - "status": "cataloged", - "types": ["source-only"], - "hasDevelop": true, - "publish": [], - "requiredSecrets": [], - "consumerModel": "pull", - "releaseTrigger": "none", - "driftNotes": ["xLights show sequences/models (asset repo).", "main is near-empty - all content lives on develop; promote to main.", "No CI/governance scaffolding."] - }, - { - "name": "Blog", - "url": "https://github.com/ptr727/Blog", - "status": "cataloged", - "types": ["hugo", "source-only"], - "groundTruthBranch": "main", - "workflowModel": "release", - "lineEndings": "lf", - "hasDevelop": true, - "publish": [{ "target": "github-release", "mechanism": "none" }, { "target": "self-hosted", "mechanism": "static-secret" }], - "requiredSecrets": [], - "consumerModel": "pull", - "releaseTrigger": "dispatch-only", - "configLayout": { "rulesetsDir": "repo-config", "pythonConfig": null }, - "driftNotes": ["Hugo static site migrated off WordPress.com, stood up 2026-08-01; release model with a dispatch-only publisher that cuts the tag and a source archive.", "lineEndings lf on a release repo, where the rule grants the native-platform default to operational repos only: every consumer is Linux (the Hugo build, the Caddy container, and the rsync deploy).", "content/ is an imported WordPress archive, so the prose, spelling, and style gates are scoped to exclude it.", "Deploy credentials are per-environment GitHub Environment secrets and variables, which spec/secrets.json has no vocabulary for, so requiredSecrets stays empty and the names are declared in this repo's own secrets.json environments block; listing them here would make the audit demand them in the repository actions store."] - } - ] -} +{ + "$schema": "./repos.schema.json", + "owner": "ptr727", + "defaults": { + "groundTruthBranch": "main", + "releaseTrigger": "two-phase", + "workflowModel": "release" + }, + "repos": [ + { + "name": "ProjectTemplate", + "url": "https://github.com/ptr727/ProjectTemplate", + "status": "cataloged", + "types": ["source-only", "docs"], + "groundTruthBranch": "main", + "hasDevelop": true, + "publish": [{ "target": "github-release", "mechanism": "none" }], + "requiredSecrets": [], + "consumerModel": "pull", + "releaseTrigger": "two-phase", + "configLayout": { "rulesetsDir": "repo-config", "pythonConfig": null }, + "driftNotes": ["Governance hub; audits its own rules against itself."] + }, + { + "name": "Utilities", + "url": "https://github.com/ptr727/Utilities", + "status": "cataloged", + "types": ["csharp", "nuget"], + "groundTruthBranch": "main", + "hasDevelop": true, + "publish": [{ "target": "nuget", "mechanism": "oidc" }], + "requiredSecrets": ["NUGET_USERNAME", "CODECOV_TOKEN"], + "consumerModel": "pull", + "releaseTrigger": "two-phase", + "configLayout": { "rulesetsDir": "repo-config", "pythonConfig": null }, + "driftNotes": ["No get-version-task; relies on validate-task."] + }, + { + "name": "LanguageTags", + "url": "https://github.com/ptr727/LanguageTags", + "status": "cataloged", + "types": ["csharp", "nuget", "codegen"], + "groundTruthBranch": "main", + "hasDevelop": true, + "publish": [{ "target": "nuget", "mechanism": "oidc" }], + "requiredSecrets": ["NUGET_USERNAME", "CODECOV_TOKEN"], + "consumerModel": "pull", + "releaseTrigger": "two-phase", + "configLayout": { "rulesetsDir": "repo-config", "pythonConfig": null }, + "driftNotes": ["No get-version-task; relies on validate-task."] + }, + { + "name": "aiopurpleair", + "url": "https://github.com/ptr727/aiopurpleair", + "status": "cataloged", + "types": ["python", "pypi"], + "groundTruthBranch": "main", + "hasDevelop": true, + "publish": [{ "target": "pypi", "mechanism": "oidc" }], + "requiredSecrets": ["CODECOV_TOKEN"], + "consumerModel": "pull", + "releaseTrigger": "publish-on-merge", + "configLayout": { "rulesetsDir": "repo-config", "pythonConfig": "pyproject.toml" }, + "driftNotes": ["Enforces a 99%+ coverage threshold, beyond the fleet's report-only codecov default."] + }, + { + "name": "homeassistant-purpleair", + "url": "https://github.com/ptr727/homeassistant-purpleair", + "status": "cataloged", + "types": ["python", "homeassistant"], + "groundTruthBranch": "main", + "hasDevelop": true, + "publish": [{ "target": "github-release", "mechanism": "none" }], + "requiredSecrets": ["CODECOV_TOKEN"], + "consumerModel": "push", + "releaseTrigger": "dispatch-only", + "configLayout": { "rulesetsDir": "repo-config", "pythonConfig": "standalone" }, + "driftNotes": [ + "ruff/pyright in standalone .ruff.toml + pyrightconfig.json instead of pyproject.toml (canonicalize).", + "Enforces a 99%+ coverage threshold, beyond the fleet's report-only codecov default." + ] + }, + { + "name": "Financial-Modeling", + "url": "https://github.com/ptr727/Financial-Modeling", + "status": "cataloged", + "types": ["python", "source-only"], + "groundTruthBranch": "main", + "hasDevelop": true, + "publish": [], + "requiredSecrets": ["CODECOV_TOKEN"], + "consumerModel": "pull", + "releaseTrigger": "dispatch-only", + "configLayout": { "pythonConfig": "pyproject.toml" }, + "driftNotes": [ + "Personal Python toolkit (uv/pyproject, src/ + tests/ + analysis/data/docs); private.", + "Source-release repo: source-only, no PyPI - a tag plus a source zip on manual dispatch (releaseTrigger dispatch-only).", + "PR CI established (test-pull-request.yml -> validate-task.yml: ruff + mypy + pytest/coverage). Python profile: mypy is the CI type checker (pyright editor-only via Pylance), deps via PEP 621 [project.optional-dependencies], static version (no _version.py).", + "Carries the AGENTS.md router split: GOVERNANCE.md with the verbatim sections, repo-specific content extracted to OPERATIONS.md, plus .markdownlint-cli2.jsonc, CODESTYLE.md, .editorconfig, .gitattributes, WORKFLOW.md, version.json + NBGV, the dispatch publisher, and dependabot.yml." + ] + }, + { + "name": "PlexCleaner", + "url": "https://github.com/ptr727/PlexCleaner", + "status": "cataloged", + "types": ["csharp", "console", "docker", "python"], + "groundTruthBranch": "main", + "hasDevelop": true, + "publish": [ + { "target": "docker", "mechanism": "static-secret" }, + { "target": "github-release", "mechanism": "none" } + ], + "requiredSecrets": ["DOCKER_HUB_USERNAME", "DOCKER_HUB_ACCESS_TOKEN", "CODECOV_TOKEN"], + "consumerModel": "pull", + "releaseTrigger": "two-phase", + "configLayout": { "rulesetsDir": "repo-config", "pythonConfig": null }, + "driftNotes": ["Carries ARCHITECTURE.md and codecov.yml beyond the baseline.", "First csharp+python repo: a C# console app at the root plus a stdlib-only Python tooling subtree (RegressionTests/, uvx scripts profile - no uv.lock, pyproject carries only ruff+mypy config; PlexCleaner#855). python.uvlock.pinned and python.coverage.codecov are N/A for that subtree (no uv project, no tests); codecov.yml stays required for the C# side. Reference for the csharp+python shape (issue #339)."] + }, + { + "name": "ESPHome-NonRoot", + "url": "https://github.com/ptr727/ESPHome-NonRoot", + "status": "cataloged", + "types": ["docker", "upstream-wrapper"], + "groundTruthBranch": "main", + "hasDevelop": true, + "publish": [{ "target": "docker", "mechanism": "static-secret" }], + "requiredSecrets": ["DOCKER_HUB_USERNAME", "DOCKER_HUB_ACCESS_TOKEN"], + "consumerModel": "pull", + "releaseTrigger": "two-phase", + "configLayout": { "rulesetsDir": "repo-config", "pythonConfig": null }, + "driftNotes": ["Upstream-version tracker drives the image tag."] + }, + { + "name": "VSCode-Server-DotNetCore", + "url": "https://github.com/ptr727/VSCode-Server-DotNetCore", + "status": "cataloged", + "types": ["docker"], + "groundTruthBranch": "main", + "hasDevelop": true, + "publish": [{ "target": "docker", "mechanism": "static-secret" }], + "requiredSecrets": ["DOCKER_HUB_USERNAME", "DOCKER_HUB_ACCESS_TOKEN"], + "consumerModel": "pull", + "releaseTrigger": "two-phase", + "configLayout": { "rulesetsDir": "repo-config", "pythonConfig": null }, + "driftNotes": ["Separate publish-docker-readme-task for the Hub overview."] + }, + { + "name": "NxWitness", + "url": "https://github.com/ptr727/NxWitness", + "status": "cataloged", + "types": ["docker", "upstream-wrapper", "codegen", "csharp"], + "groundTruthBranch": "main", + "hasDevelop": true, + "publish": [{ "target": "docker", "mechanism": "static-secret" }, { "target": "github-release", "mechanism": "none" }], + "requiredSecrets": ["DOCKER_HUB_USERNAME", "DOCKER_HUB_ACCESS_TOKEN", "CODECOV_TOKEN"], + "consumerModel": "pull", + "releaseTrigger": "two-phase", + "driftNotes": ["Docker image wrapping upstream Nx products; C# (CreateMatrix) is the codegen generator, not a shipped package (IsPackable=false, no nuget push).", "Release is the two-phase model (weekly schedule + workflow_dispatch publish; ordinary merges do not) plus an extra Make/Matrix.json path-scoped push that republishes when the codegen version pin bumps.", "Docker Hub README published per-image via a Matrix.json-derived matrix.", "Branch hygiene: 3 stale Dependabot nuget branches (PRs closed/superseded) linger, safe to delete; main+develop otherwise clean after the 2026-07 sweep."] + }, + { + "name": "HomeAutomation-Config", + "url": "https://github.com/ptr727/HomeAutomation-Config", + "status": "cataloged", + "types": ["source-only"], + "groundTruthBranch": "main", + "workflowModel": "operational", + "lineEndings": "lf", + "hasDevelop": true, + "publish": [{ "target": "github-release", "mechanism": "none" }], + "requiredSecrets": [], + "consumerModel": "pull", + "releaseTrigger": "dispatch-only", + "driftNotes": ["Maintainer config/ops repo (docker-compose stacks, lifecycle scripts, Firewalla configs); Linux-consumed on the Proxmox host, so lineEndings lf.", "Renamed from HomeAutomation for fleet naming consistency (config repos are *-Config). The Vantage controller config is split out to its own Windows/CRLF Vantage-Config repo, not carried here; the legacy Vantage/ subtree is stripped."] + }, + { + "name": "KiCadLibrary", + "url": "https://github.com/ptr727/KiCadLibrary", + "status": "cataloged", + "types": ["eda"], + "hasDevelop": true, + "publish": [{ "target": "github-release", "mechanism": "none" }], + "requiredSecrets": [], + "consumerModel": "pull", + "releaseTrigger": "two-phase", + "driftNotes": ["EDA/KiCad part library; delivers a github-release data zip.", "main is stale (data + README only): the full fleet CI, NBGV version.json, and the Python build/verify pipeline live only on develop - promote to main to converge.", "Python tooling uses requirements-dev.txt, not pyproject.toml; no repo-config/ rulesets."] + }, + { + "name": "EspDinIoT", + "url": "https://github.com/ptr727/EspDinIoT", + "status": "cataloged", + "types": ["eda"], + "hasDevelop": true, + "publish": [], + "requiredSecrets": [], + "consumerModel": "pull", + "releaseTrigger": "none", + "driftNotes": ["EDA/KiCad PCB design; main is a stub - the real design lives on develop and feature/schematic, populate main.", "No CI on any branch; develop has partial governance adoption (AGENTS.md/.editorconfig) but no workflows."] + }, + { + "name": "ESPHome-Config", + "url": "https://github.com/ptr727/ESPHome-Config", + "status": "cataloged", + "types": ["source-only", "python", "cpp"], + "profiles": { "python": "lint-only", "cpp": "lint-only" }, + "groundTruthBranch": "main", + "workflowModel": "operational", + "lineEndings": "lf", + "hasDevelop": true, + "publish": [{ "target": "github-release", "mechanism": "none" }], + "requiredSecrets": [], + "consumerModel": "pull", + "releaseTrigger": "dispatch-only", + "driftNotes": ["ESPHome device config YAML consumed by the cataloged ESPHome-NonRoot image at runtime. Distinct from that Docker repo.", "First repo with a lint-only python codegen subtree (easystart/python: ruff and pyright config only, no tests, no uv.lock) and lint-only cpp (committed custom-component and template headers, formatted by a shared clang-format config)."] + }, + { + "name": "HomeAssistant-Config", + "url": "https://github.com/ptr727/HomeAssistant-Config", + "status": "cataloged", + "types": ["source-only"], + "groundTruthBranch": "main", + "workflowModel": "operational", + "lineEndings": "lf", + "hasDevelop": true, + "publish": [{ "target": "github-release", "mechanism": "none" }], + "requiredSecrets": [], + "consumerModel": "pull", + "releaseTrigger": "dispatch-only", + "driftNotes": ["Home Assistant CONFIGURATION (configuration.yaml + automations/blueprints), NOT a HACS integration (no custom_components/manifest.json, no hacs.json).", "Operational onboarding completed 2026-07-17 (HomeAssistant-Config #16): master->main rename + develop created, advisory lint CI (Check pull request workflow status job required check), dispatch-only source release (version.json + NBGV + publish-release.yml), repo-config operational carry (rulesets/settings applied and verified in sync), Dependabot + App merge-bot with the CODEGEN_APP_* pair in both stores, adapted self-audit (AUDIT.md + spec/secrets.json); baseline promoted develop->main via HomeAssistant-Config #17.", "groundTruthBranch intentionally main: develop is the working branch (direct signed commits), main the promoted stable snapshot the audit targets - deliberately not flipped to develop (ptr727/ProjectTemplate#340).", "Private; deployed by git pull into the HA config dir."] + }, + { + "name": "DevKitCIoT", + "url": "https://github.com/ptr727/DevKitCIoT", + "status": "cataloged", + "types": ["eda"], + "groundTruthBranch": "main", + "hasDevelop": true, + "publish": [], + "requiredSecrets": [], + "consumerModel": "pull", + "releaseTrigger": "none", + "driftNotes": ["EDA/KiCad PCB design + fabrication data (gerbers/BOM); no CI on any branch, no release pipeline.", "Depends on KiCadLibrary as an upstream part source (manual git clone).", "Branch hygiene: develop and main have diverged (not forward-only); feature/kicad10-upgrade is merged (PR#4) but retained for the in-progress KiCad 10 migration. A stale THT branch was removed in the 2026-07 sweep."] + }, + { + "name": "PhotoCleaner", + "url": "https://github.com/ptr727/PhotoCleaner", + "status": "cataloged", + "types": ["csharp", "console", "docker"], + "hasDevelop": true, + "publish": [{ "target": "docker", "mechanism": "static-secret" }, { "target": "github-release", "mechanism": "none" }], + "requiredSecrets": ["DOCKER_HUB_USERNAME", "DOCKER_HUB_ACCESS_TOKEN", "CODECOV_TOKEN"], + "consumerModel": "pull", + "releaseTrigger": "two-phase", + "driftNotes": ["Multi-arch Docker (linux/amd64,linux/arm64 on main) pushed to Docker Hub via the static DOCKER_HUB_USERNAME/DOCKER_HUB_ACCESS_TOKEN secret, plus a github-release attaching the multi-arch executables as a 7z. Release is two-phase (workflow_dispatch + weekly Mon 02:00 schedule).", "Private for now by maintainer decision, going public once the conformance work settles, since public serves prospective users and lowers the GitHub bill. Until that flip the declared github-release channel is not consumer-pullable and shields.io cannot read the repo, so the GitHub-sourced shields render broken."] + }, + { + "name": "MediaTools", + "url": "https://github.com/ptr727/MediaTools", + "status": "cataloged", + "types": ["csharp", "nuget"], + "groundTruthBranch": "main", + "hasDevelop": true, + "publish": [{ "target": "nuget", "mechanism": "oidc" }], + "requiredSecrets": ["NUGET_USERNAME", "CODECOV_TOKEN"], + "consumerModel": "pull", + "releaseTrigger": "two-phase", + "driftNotes": ["Rulesets applied from the hub canonical repo-config/, not committed in-repo (no repo-config/ directory), as with NxWitness.", "No WORKFLOW.md sibling doc (pending fleet-wide ratification, ptr727/ProjectTemplate#223); workflow comments carry the rationale inline."] + }, + { + "name": "AudioCleaner", + "url": "https://github.com/ptr727/AudioCleaner", + "status": "cataloged", + "types": ["csharp", "console"], + "groundTruthBranch": "main", + "hasDevelop": true, + "publish": [], + "requiredSecrets": ["CODECOV_TOKEN"], + "consumerModel": "pull", + "releaseTrigger": "none", + "driftNotes": ["Work-in-progress: pre-CI (no .github/workflows, no version.json, no repo-config). main+develop both exist."] + }, + { + "name": "Vantage-Config", + "url": "https://github.com/ptr727/Vantage-Config", + "status": "cataloged", + "types": ["source-only"], + "groundTruthBranch": "main", + "workflowModel": "operational", + "lineEndings": "crlf", + "hasDevelop": true, + "publish": [{ "target": "github-release", "mechanism": "none" }], + "requiredSecrets": [], + "consumerModel": "pull", + "releaseTrigger": "dispatch-only", + "driftNotes": ["Vantage InFusion / Design Center controller config edited on Windows (UTF-8 CRLF .dc XML, really special XML), so lineEndings crlf.", "Recreated lean and single-platform: Design Center is freely available, so no installer archives are kept; split out of HomeAutomation-Config.", "Operational onboarding completed 2026-07-16 (Vantage-Config #9): baseline docs, advisory lint CI, dispatch-only publisher, repo-config operational carry (rulesets/settings applied and verified in sync), Dependabot + App merge-bot with the secret pair in both stores, adapted self-audit (AUDIT.md + spec/secrets.json)."] + }, + { + "name": "HolidayLights", + "url": "https://github.com/ptr727/HolidayLights", + "status": "cataloged", + "types": ["source-only"], + "hasDevelop": true, + "publish": [], + "requiredSecrets": [], + "consumerModel": "pull", + "releaseTrigger": "none", + "driftNotes": ["xLights show sequences/models (asset repo).", "main is near-empty - all content lives on develop; promote to main.", "No CI/governance scaffolding."] + }, + { + "name": "Blog", + "url": "https://github.com/ptr727/Blog", + "status": "cataloged", + "types": ["hugo", "source-only"], + "groundTruthBranch": "main", + "workflowModel": "release", + "lineEndings": "lf", + "hasDevelop": true, + "publish": [{ "target": "github-release", "mechanism": "none" }, { "target": "self-hosted", "mechanism": "static-secret" }], + "requiredSecrets": [], + "consumerModel": "pull", + "releaseTrigger": "dispatch-only", + "configLayout": { "rulesetsDir": "repo-config", "pythonConfig": null }, + "driftNotes": ["Hugo static site migrated off WordPress.com, stood up 2026-08-01; release model with a dispatch-only publisher that cuts the tag and a source archive.", "lineEndings lf on a release repo, where the rule grants the native-platform default to operational repos only: every consumer is Linux (the Hugo build, the Caddy container, and the rsync deploy).", "content/ is an imported WordPress archive, so the prose, spelling, and style gates are scoped to exclude it.", "Deploy credentials are per-environment GitHub Environment secrets and variables, which spec/secrets.json has no vocabulary for, so requiredSecrets stays empty and the names are declared in this repo's own secrets.json environments block; listing them here would make the audit demand them in the repository actions store."] + } + ] +} diff --git a/registry/repos.schema.json b/registry/repos.schema.json index fac95ffd..92164cfd 100644 --- a/registry/repos.schema.json +++ b/registry/repos.schema.json @@ -1,80 +1,80 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/ptr727/ProjectTemplate/registry/repos.schema.json", - "title": "Fleet registry", - "type": "object", - "required": ["owner", "repos"], - "additionalProperties": false, - "properties": { - "$schema": { "type": "string" }, - "owner": { "type": "string" }, - "defaults": { - "type": "object", - "additionalProperties": false, - "properties": { - "groundTruthBranch": { "type": "string" }, - "releaseTrigger": { "$ref": "#/$defs/releaseTrigger" }, - "workflowModel": { "$ref": "#/$defs/workflowModel" } - } - }, - "repos": { - "type": "array", - "items": { "$ref": "#/$defs/repo" } - } - }, - "$defs": { - "releaseTrigger": { "enum": ["two-phase", "publish-on-merge", "dispatch-only", "none"] }, - "workflowModel": { "enum": ["release", "operational"] }, - "lineEndings": { "enum": ["lf", "crlf"] }, - "mechanism": { "enum": ["oidc", "static-secret", "none"] }, - "target": { "enum": ["nuget", "pypi", "docker", "github-release", "self-hosted"] }, - "repo": { - "type": "object", - "required": ["name", "url", "status"], - "allOf": [ - { - "$comment": "An operational repo must declare its line endings (release repos use the fleet CRLF default).", - "if": { "properties": { "workflowModel": { "const": "operational" } }, "required": ["workflowModel"] }, - "then": { "required": ["lineEndings"] } - } - ], - "additionalProperties": false, - "properties": { - "name": { "type": "string" }, - "url": { "type": "string", "format": "uri" }, - "status": { "enum": ["cataloged", "backlog"] }, - "types": { "type": "array", "items": { "type": "string" } }, - "profiles": { "type": "object", "additionalProperties": { "enum": ["lint-only", "build"] } }, - "classificationPending": { "type": "boolean" }, - "groundTruthBranch": { "type": "string" }, - "workflowModel": { "$ref": "#/$defs/workflowModel" }, - "lineEndings": { "$ref": "#/$defs/lineEndings" }, - "hasDevelop": { "type": "boolean" }, - "publish": { - "type": "array", - "items": { - "type": "object", - "required": ["target", "mechanism"], - "additionalProperties": false, - "properties": { - "target": { "$ref": "#/$defs/target" }, - "mechanism": { "$ref": "#/$defs/mechanism" } - } - } - }, - "requiredSecrets": { "type": "array", "items": { "type": "string" } }, - "consumerModel": { "enum": ["push", "pull"] }, - "releaseTrigger": { "$ref": "#/$defs/releaseTrigger" }, - "configLayout": { - "type": "object", - "additionalProperties": false, - "properties": { - "rulesetsDir": { "type": "string" }, - "pythonConfig": { "type": ["string", "null"] } - } - }, - "driftNotes": { "type": "array", "items": { "type": "string" } } - } - } - } -} +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/ptr727/ProjectTemplate/registry/repos.schema.json", + "title": "Fleet registry", + "type": "object", + "required": ["owner", "repos"], + "additionalProperties": false, + "properties": { + "$schema": { "type": "string" }, + "owner": { "type": "string" }, + "defaults": { + "type": "object", + "additionalProperties": false, + "properties": { + "groundTruthBranch": { "type": "string" }, + "releaseTrigger": { "$ref": "#/$defs/releaseTrigger" }, + "workflowModel": { "$ref": "#/$defs/workflowModel" } + } + }, + "repos": { + "type": "array", + "items": { "$ref": "#/$defs/repo" } + } + }, + "$defs": { + "releaseTrigger": { "enum": ["two-phase", "publish-on-merge", "dispatch-only", "none"] }, + "workflowModel": { "enum": ["release", "operational"] }, + "lineEndings": { "enum": ["lf", "crlf"] }, + "mechanism": { "enum": ["oidc", "static-secret", "none"] }, + "target": { "enum": ["nuget", "pypi", "docker", "github-release", "self-hosted"] }, + "repo": { + "type": "object", + "required": ["name", "url", "status"], + "allOf": [ + { + "$comment": "An operational repo must declare its line endings (release repos use the fleet LF default).", + "if": { "properties": { "workflowModel": { "const": "operational" } }, "required": ["workflowModel"] }, + "then": { "required": ["lineEndings"] } + } + ], + "additionalProperties": false, + "properties": { + "name": { "type": "string" }, + "url": { "type": "string", "format": "uri" }, + "status": { "enum": ["cataloged", "backlog"] }, + "types": { "type": "array", "items": { "type": "string" } }, + "profiles": { "type": "object", "additionalProperties": { "enum": ["lint-only", "build"] } }, + "classificationPending": { "type": "boolean" }, + "groundTruthBranch": { "type": "string" }, + "workflowModel": { "$ref": "#/$defs/workflowModel" }, + "lineEndings": { "$ref": "#/$defs/lineEndings" }, + "hasDevelop": { "type": "boolean" }, + "publish": { + "type": "array", + "items": { + "type": "object", + "required": ["target", "mechanism"], + "additionalProperties": false, + "properties": { + "target": { "$ref": "#/$defs/target" }, + "mechanism": { "$ref": "#/$defs/mechanism" } + } + } + }, + "requiredSecrets": { "type": "array", "items": { "type": "string" } }, + "consumerModel": { "enum": ["push", "pull"] }, + "releaseTrigger": { "$ref": "#/$defs/releaseTrigger" }, + "configLayout": { + "type": "object", + "additionalProperties": false, + "properties": { + "rulesetsDir": { "type": "string" }, + "pythonConfig": { "type": ["string", "null"] } + } + }, + "driftNotes": { "type": "array", "items": { "type": "string" } } + } + } + } +} diff --git a/repo-config/README.md b/repo-config/README.md index dc67d969..9399bf3f 100644 --- a/repo-config/README.md +++ b/repo-config/README.md @@ -1,39 +1,39 @@ -# repo-config - -Repository and branch configuration held as committed files, kept out of `.github/` (which holds the GitHub-consumed configuration: workflows, Dependabot). - -- `main.json` plus one `develop` variant: the branch rulesets as the managed part of the writable API subset (`name`, `target`, `enforcement`, `conditions`, `rules`). The `develop` payload is `develop.json` (`release` repos) or `operational/develop.json` (`operational` repos). These are the canonical expected payloads that the self-audit (`AUDIT.md`) diffs the live rulesets against. `bypass_actors` is writable and deliberately unmanaged, so no payload declares one and nothing diffs it: who may bypass a ruleset is a human decision taken in the UI, which `configure.sh` preserves on `apply` and reports without asserting on `check`. -- `operational/develop.json`: the `develop` ruleset for **operational** repos (registry `workflowModel: operational`), taking direct signed pushes with no PR gate. Present in operational repos only, since a `release` repo does not have it. See "Rulesets" below. -- `configure.sh`: **hosted in the hub and run from a hub checkout, not carried here**, per [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling]. The payloads above are what this repo is audited against and stay with it. The script holds nothing per-repo and is one copy for the fleet, and it resolves every payload path against its own directory rather than against the target repo, so a run from a hub checkout compares this repo against the hub's payloads and never reads the copies above. Name the target repository explicitly, since the command defaults to whichever repository the shell is sitting in. Two modes over the GitHub API. `configure.sh apply owner/repo release|operational` creates-or-updates the settings, the Dependabot security features, and the rulesets idempotently (a full-payload update). `configure.sh check owner/repo release|operational` is the read-only inverse and exits non-zero on any drift, with the ruleset and settings assertions driven by the committed payloads so they stay repo-agnostic (rule presence, merge methods, and required checks, not a byte diff, so a GitHub-normalized stored ruleset does not false-positive). The command defaults to `apply`, the repo to the current one, and the model to the registry `workflowModel` lookup, falling back to inference from the carried `develop` payload where there is no registry to consult and aborting rather than guessing on an ambiguous layout. Pass the model rather than leaving it to the lookup. A hub checkout has the registry, so a repo the registry does not yet name resolves through `defaults.workflowModel` to `release`, which applies the release `develop` ruleset to an operational repo. Both arguments are optional to the parser rather than to the caller, which is the hazard: a lone positional naming a model is read as the model, so `configure.sh check operational` resolves against whichever repository the shell is in rather than erroring on a missing target. - -## Rulesets - -Two workflow models share `main.json` but differ on `develop` (registry `workflowModel`, default `release`): - -- **`release`** (`develop.json`): `develop` requires squash merges with linear history and a PR, the feature-branch pipeline. -- **`operational`** (`operational/develop.json`): `develop` takes **direct signed pushes**, carrying only `deletion`, `non_fast_forward`, and `required_signatures`; no PR, no status-check, no Copilot-on-push. CI runs on the push as advisory feedback. Read the dropped rules as an allowance rather than a prohibition, since a PR into `develop` remains legal and the lint workflow triggers on it, with its result reported and not required (a required check here would gate the direct push as well). This is for live-service config repos that edit `develop` directly and promote a known-good snapshot to `main` via an occasional PR (see [GOVERNANCE.md "Branching Model"][governance-branching-model]). - -`main` (both models) requires merge-commit merges (no linear-history rule), signed commits, a passing `Check pull request workflow status job`, resolved review threads, and Copilot review, and blocks force-pushes and deletion, so a `develop -> main` promotion is always gated even when `develop` takes direct commits. Every ruleset intentionally leaves "Require branches to be up to date before merging" **off**, per [GOVERNANCE.md "Branching Model"][governance-branching-model]. - -The result is **exactly two rulesets named `develop` and `main`**, and the names are load-bearing (`GOVERNANCE.md` and the workflows reference them). Only the `develop` *content* varies by model. The required check binds by name and only turns green after the repo's PR workflow runs once. - -## Secrets - -Publish credentials required per mechanism are enumerated in `spec/secrets.json`. A repo needs only the mechanisms its own publish targets use, so a source-only repo needs none of the publish credentials below. NuGet and PyPI use keyless OIDC Trusted Publishing (no stored key, so the publish job needs `id-token: write`, and PyPI additionally an `environment: pypi` gate). Docker Hub has no OIDC equivalent and uses a stored `DOCKER_HUB_USERNAME` + `DOCKER_HUB_ACCESS_TOKEN` in both the Actions and Dependabot secret stores. Codegen and merge-bot repos add a GitHub App (`CODEGEN_APP_CLIENT_ID` + `CODEGEN_APP_PRIVATE_KEY` in both stores, and the app must be installed, not just created). App-token call sites use `client-id`, never the deprecated `app-id`. - -## Repo Settings - -The fleet-standard general settings live in [`settings.json`][settings-json] and are applied idempotently by `configure.sh apply` alongside the rulesets (`gh api PATCH /repos/{owner}/{repo}`). The two settings that depend on per-repo state, `has_discussions` (visibility) and `default_branch` (main-must-exist), are computed by the script, not stored in the file. `configure.sh apply` also enables Dependabot vulnerability alerts and automated security updates, fleet policy applied via the API rather than a `settings.json` key. `configure.sh check` validates all of these and exits non-zero on drift. - -- **Default branch `main`** (the script sets it only when a `main` branch exists, never pointing the default at a missing branch). -- **Merge methods**: `Allow merge commits` and `Allow squash merging` on, **rebase off**, and each branch ruleset then picks its method (merge on `main`, squash on `develop`). -- **Auto-merge on** (the merge-bot needs it) and **`Always suggest updating pull request branches` on**. -- **`Automatically delete head branches` is OFF, deliberately.** With it on, a `develop -> main` promotion (whose PR head is `develop`) would delete `develop`. There is no per-branch exemption, so the repo-wide toggle stays off to protect `develop`. **The CLI has the same trap: never `gh pr merge --delete-branch` a promotion PR whose head is `develop`**, since the explicit flag deletes `develop` regardless of this setting (see [GOVERNANCE.md "Branching Model"][governance-branching-model]). -- **Wikis and Projects off. Discussions on public repos only** (off on private). **Sponsorships off**, since the button is driven by `.github/FUNDING.yml` rather than a REST toggle, and the fleet ships none. -- **Actions / General**: allow GitHub Actions to create and approve pull requests (for the bots). - -<!-- Repo --> - -[governance-branching-model]: ../GOVERNANCE.md#branching-model -[governance-hub-hosted-tooling]: ../GOVERNANCE.md#hub-hosted-tooling -[settings-json]: ./settings.json +# repo-config + +Repository and branch configuration held as committed files, kept out of `.github/` (which holds the GitHub-consumed configuration: workflows, Dependabot). + +- `main.json` plus one `develop` variant: the branch rulesets as the managed part of the writable API subset (`name`, `target`, `enforcement`, `conditions`, `rules`). The `develop` payload is `develop.json` (`release` repos) or `operational/develop.json` (`operational` repos). These are the canonical expected payloads that the self-audit (`AUDIT.md`) diffs the live rulesets against. `bypass_actors` is writable and deliberately unmanaged, so no payload declares one and nothing diffs it: who may bypass a ruleset is a human decision taken in the UI, which `configure.sh` preserves on `apply` and reports without asserting on `check`. +- `operational/develop.json`: the `develop` ruleset for **operational** repos (registry `workflowModel: operational`), taking direct signed pushes with no PR gate. Present in operational repos only, since a `release` repo does not have it. See "Rulesets" below. +- `configure.sh`: **hosted in the hub and run from a hub checkout, not carried here**, per [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling]. The payloads above are what this repo is audited against and stay with it. The script holds nothing per-repo and is one copy for the fleet, and it resolves every payload path against its own directory rather than against the target repo, so a run from a hub checkout compares this repo against the hub's payloads and never reads the copies above. Name the target repository explicitly, since the command defaults to whichever repository the shell is sitting in. Two modes over the GitHub API. `configure.sh apply owner/repo release|operational` creates-or-updates the settings, the Dependabot security features, and the rulesets idempotently (a full-payload update). `configure.sh check owner/repo release|operational` is the read-only inverse and exits non-zero on any drift, with the ruleset and settings assertions driven by the committed payloads so they stay repo-agnostic (rule presence, merge methods, and required checks, not a byte diff, so a GitHub-normalized stored ruleset does not false-positive). The command defaults to `apply`, the repo to the current one, and the model to the registry `workflowModel` lookup, falling back to inference from the carried `develop` payload where there is no registry to consult and aborting rather than guessing on an ambiguous layout. Pass the model rather than leaving it to the lookup. A hub checkout has the registry, so a repo the registry does not yet name resolves through `defaults.workflowModel` to `release`, which applies the release `develop` ruleset to an operational repo. Both arguments are optional to the parser rather than to the caller, which is the hazard: a lone positional naming a model is read as the model, so `configure.sh check operational` resolves against whichever repository the shell is in rather than erroring on a missing target. + +## Rulesets + +Two workflow models share `main.json` but differ on `develop` (registry `workflowModel`, default `release`): + +- **`release`** (`develop.json`): `develop` requires squash merges with linear history and a PR, the feature-branch pipeline. +- **`operational`** (`operational/develop.json`): `develop` takes **direct signed pushes**, carrying only `deletion`, `non_fast_forward`, and `required_signatures`; no PR, no status-check, no Copilot-on-push. CI runs on the push as advisory feedback. Read the dropped rules as an allowance rather than a prohibition, since a PR into `develop` remains legal and the lint workflow triggers on it, with its result reported and not required (a required check here would gate the direct push as well). This is for live-service config repos that edit `develop` directly and promote a known-good snapshot to `main` via an occasional PR (see [GOVERNANCE.md "Branching Model"][governance-branching-model]). + +`main` (both models) requires merge-commit merges (no linear-history rule), signed commits, a passing `Check pull request workflow status job`, resolved review threads, and Copilot review, and blocks force-pushes and deletion, so a `develop -> main` promotion is always gated even when `develop` takes direct commits. Every ruleset intentionally leaves "Require branches to be up to date before merging" **off**, per [GOVERNANCE.md "Branching Model"][governance-branching-model]. + +The result is **exactly two rulesets named `develop` and `main`**, and the names are load-bearing (`GOVERNANCE.md` and the workflows reference them). Only the `develop` *content* varies by model. The required check binds by name and only turns green after the repo's PR workflow runs once. + +## Secrets + +Publish credentials required per mechanism are enumerated in `spec/secrets.json`. A repo needs only the mechanisms its own publish targets use, so a source-only repo needs none of the publish credentials below. NuGet and PyPI use keyless OIDC Trusted Publishing (no stored key, so the publish job needs `id-token: write`, and PyPI additionally an `environment: pypi` gate). Docker Hub has no OIDC equivalent and uses a stored `DOCKER_HUB_USERNAME` + `DOCKER_HUB_ACCESS_TOKEN` in both the Actions and Dependabot secret stores. Codegen and merge-bot repos add a GitHub App (`CODEGEN_APP_CLIENT_ID` + `CODEGEN_APP_PRIVATE_KEY` in both stores, and the app must be installed, not just created). App-token call sites use `client-id`, never the deprecated `app-id`. + +## Repo Settings + +The fleet-standard general settings live in [`settings.json`][settings-json] and are applied idempotently by `configure.sh apply` alongside the rulesets (`gh api PATCH /repos/{owner}/{repo}`). The two settings that depend on per-repo state, `has_discussions` (visibility) and `default_branch` (main-must-exist), are computed by the script, not stored in the file. `configure.sh apply` also enables Dependabot vulnerability alerts and automated security updates, fleet policy applied via the API rather than a `settings.json` key. `configure.sh check` validates all of these and exits non-zero on drift. + +- **Default branch `main`** (the script sets it only when a `main` branch exists, never pointing the default at a missing branch). +- **Merge methods**: `Allow merge commits` and `Allow squash merging` on, **rebase off**, and each branch ruleset then picks its method (merge on `main`, squash on `develop`). +- **Auto-merge on** (the merge-bot needs it) and **`Always suggest updating pull request branches` on**. +- **`Automatically delete head branches` is OFF, deliberately.** With it on, a `develop -> main` promotion (whose PR head is `develop`) would delete `develop`. There is no per-branch exemption, so the repo-wide toggle stays off to protect `develop`. **The CLI has the same trap: never `gh pr merge --delete-branch` a promotion PR whose head is `develop`**, since the explicit flag deletes `develop` regardless of this setting (see [GOVERNANCE.md "Branching Model"][governance-branching-model]). +- **Wikis and Projects off. Discussions on public repos only** (off on private). **Sponsorships off**, since the button is driven by `.github/FUNDING.yml` rather than a REST toggle, and the fleet ships none. +- **Actions / General**: allow GitHub Actions to create and approve pull requests (for the bots). + +<!-- Repo --> + +[governance-branching-model]: ../GOVERNANCE.md#branching-model +[governance-hub-hosted-tooling]: ../GOVERNANCE.md#hub-hosted-tooling +[settings-json]: ./settings.json diff --git a/repo-config/develop.json b/repo-config/develop.json index d34d9f8f..16c89f45 100644 --- a/repo-config/develop.json +++ b/repo-config/develop.json @@ -1,61 +1,61 @@ -{ - "conditions": { - "ref_name": { - "exclude": [], - "include": [ - "refs/heads/develop" - ] - } - }, - "enforcement": "active", - "name": "develop", - "rules": [ - { - "type": "deletion" - }, - { - "type": "non_fast_forward" - }, - { - "type": "required_linear_history" - }, - { - "type": "required_signatures" - }, - { - "parameters": { - "allowed_merge_methods": [ - "squash" - ], - "dismiss_stale_reviews_on_push": true, - "require_code_owner_review": false, - "require_last_push_approval": false, - "required_approving_review_count": 0, - "required_review_thread_resolution": true, - "required_reviewers": [] - }, - "type": "pull_request" - }, - { - "parameters": { - "do_not_enforce_on_create": false, - "required_status_checks": [ - { - "context": "Check pull request workflow status job", - "integration_id": 15368 - } - ], - "strict_required_status_checks_policy": false - }, - "type": "required_status_checks" - }, - { - "parameters": { - "review_draft_pull_requests": true, - "review_on_push": true - }, - "type": "copilot_code_review" - } - ], - "target": "branch" -} +{ + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/develop" + ] + } + }, + "enforcement": "active", + "name": "develop", + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_linear_history" + }, + { + "type": "required_signatures" + }, + { + "parameters": { + "allowed_merge_methods": [ + "squash" + ], + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_approving_review_count": 0, + "required_review_thread_resolution": true, + "required_reviewers": [] + }, + "type": "pull_request" + }, + { + "parameters": { + "do_not_enforce_on_create": false, + "required_status_checks": [ + { + "context": "Check pull request workflow status job", + "integration_id": 15368 + } + ], + "strict_required_status_checks_policy": false + }, + "type": "required_status_checks" + }, + { + "parameters": { + "review_draft_pull_requests": true, + "review_on_push": true + }, + "type": "copilot_code_review" + } + ], + "target": "branch" +} diff --git a/repo-config/main.json b/repo-config/main.json index 57bd647d..a99ed24a 100644 --- a/repo-config/main.json +++ b/repo-config/main.json @@ -1,58 +1,58 @@ -{ - "conditions": { - "ref_name": { - "exclude": [], - "include": [ - "refs/heads/main" - ] - } - }, - "enforcement": "active", - "name": "main", - "rules": [ - { - "type": "deletion" - }, - { - "type": "non_fast_forward" - }, - { - "type": "required_signatures" - }, - { - "parameters": { - "allowed_merge_methods": [ - "merge" - ], - "dismiss_stale_reviews_on_push": true, - "require_code_owner_review": false, - "require_last_push_approval": false, - "required_approving_review_count": 0, - "required_review_thread_resolution": true, - "required_reviewers": [] - }, - "type": "pull_request" - }, - { - "parameters": { - "do_not_enforce_on_create": false, - "required_status_checks": [ - { - "context": "Check pull request workflow status job", - "integration_id": 15368 - } - ], - "strict_required_status_checks_policy": false - }, - "type": "required_status_checks" - }, - { - "parameters": { - "review_draft_pull_requests": true, - "review_on_push": true - }, - "type": "copilot_code_review" - } - ], - "target": "branch" -} +{ + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/main" + ] + } + }, + "enforcement": "active", + "name": "main", + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_signatures" + }, + { + "parameters": { + "allowed_merge_methods": [ + "merge" + ], + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_approving_review_count": 0, + "required_review_thread_resolution": true, + "required_reviewers": [] + }, + "type": "pull_request" + }, + { + "parameters": { + "do_not_enforce_on_create": false, + "required_status_checks": [ + { + "context": "Check pull request workflow status job", + "integration_id": 15368 + } + ], + "strict_required_status_checks_policy": false + }, + "type": "required_status_checks" + }, + { + "parameters": { + "review_draft_pull_requests": true, + "review_on_push": true + }, + "type": "copilot_code_review" + } + ], + "target": "branch" +} diff --git a/repo-config/operational/develop.json b/repo-config/operational/develop.json index 9cab7415..7657ac18 100644 --- a/repo-config/operational/develop.json +++ b/repo-config/operational/develop.json @@ -1,24 +1,24 @@ -{ - "conditions": { - "ref_name": { - "exclude": [], - "include": [ - "refs/heads/develop" - ] - } - }, - "enforcement": "active", - "name": "develop", - "rules": [ - { - "type": "deletion" - }, - { - "type": "non_fast_forward" - }, - { - "type": "required_signatures" - } - ], - "target": "branch" -} +{ + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/develop" + ] + } + }, + "enforcement": "active", + "name": "develop", + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_signatures" + } + ], + "target": "branch" +} diff --git a/repo-config/settings.json b/repo-config/settings.json index 96ae7735..f0f3a2ff 100644 --- a/repo-config/settings.json +++ b/repo-config/settings.json @@ -1,10 +1,10 @@ -{ - "has_wiki": false, - "has_projects": false, - "allow_merge_commit": true, - "allow_squash_merge": true, - "allow_rebase_merge": false, - "allow_auto_merge": true, - "allow_update_branch": true, - "delete_branch_on_merge": false -} +{ + "has_wiki": false, + "has_projects": false, + "allow_merge_commit": true, + "allow_squash_merge": true, + "allow_rebase_merge": false, + "allow_auto_merge": true, + "allow_update_branch": true, + "delete_branch_on_merge": false +} diff --git a/reports/_template.md b/reports/_template.md index df048e5d..621080ce 100644 --- a/reports/_template.md +++ b/reports/_template.md @@ -1,41 +1,41 @@ -# Audit: `<repo>` - -- **Audited branch:** main (`<sha>`) -- **Types:** `<from registry, or resolved>` -- **Verdict:** operational | not operational -- **Date:** `<YYYY-MM-DD>` - -## Develop Drift - -`develop` vs `main`: ahead `<n>`, behind `<n>`. Classify by **content**, not commit count: promotion merge commits leave `main` permanently ahead with identical trees (benign, inherent to merge-commit promotions - a cherry-pick would be an empty no-op). A drift finding only when `main` carries content `develop` lacks; the audit's content-based branch check is the authority. - -## Dimensions - -| Dimension | Letter | Intent | Verdict | Evidence (file:line) | -| --- | --- | --- | --- | --- | -| csharp | | | | | -| nuget | | | | | -| pypi | | | | | -| python | | | | | -| console | | | | | -| docker | | | | | -| branch-model | | | | | -| repo-setup | | | | | -| linter-parity | | | | | -| recurring-violations | | | | | -| readme-structure | | | | | -| workflow (WORKFLOW.md 5A/5B) | | | | | - -Verdict values: pass | drift | defect | N/A. Remove rows that are N/A for the repo's types, or mark them N/A. - -## Defects (most severe first) - -1. `<defect>` - input/condition -> observed vs expected, at `file:line`. - -## Drift Findings - -- `<letter miss, intent satisfied>` - `file:line`. - -## Proposed Registry / Spec Updates - -- `<e.g. resolve classificationPending: types = [...]>` +# Audit: `<repo>` + +- **Audited branch:** main (`<sha>`) +- **Types:** `<from registry, or resolved>` +- **Verdict:** operational | not operational +- **Date:** `<YYYY-MM-DD>` + +## Develop Drift + +`develop` vs `main`: ahead `<n>`, behind `<n>`. Classify by **content**, not commit count: promotion merge commits leave `main` permanently ahead with identical trees (benign, inherent to merge-commit promotions - a cherry-pick would be an empty no-op). A drift finding only when `main` carries content `develop` lacks; the audit's content-based branch check is the authority. + +## Dimensions + +| Dimension | Letter | Intent | Verdict | Evidence (file:line) | +| --- | --- | --- | --- | --- | +| csharp | | | | | +| nuget | | | | | +| pypi | | | | | +| python | | | | | +| console | | | | | +| docker | | | | | +| branch-model | | | | | +| repo-setup | | | | | +| linter-parity | | | | | +| recurring-violations | | | | | +| readme-structure | | | | | +| workflow (WORKFLOW.md 5A/5B) | | | | | + +Verdict values: pass | drift | defect | N/A. Remove rows that are N/A for the repo's types, or mark them N/A. + +## Defects (most severe first) + +1. `<defect>` - input/condition -> observed vs expected, at `file:line`. + +## Drift Findings + +- `<letter miss, intent satisfied>` - `file:line`. + +## Proposed Registry / Spec Updates + +- `<e.g. resolve classificationPending: types = [...]>` diff --git a/reports/aiopurpleair/audit.md b/reports/aiopurpleair/audit.md index bdb4d376..819bfa6e 100644 --- a/reports/aiopurpleair/audit.md +++ b/reports/aiopurpleair/audit.md @@ -1,98 +1,98 @@ -# Audit: aiopurpleair - -- **Audited branch:** main (`3c8001b16b091cc20c881531638946b8a3aa9c9b`) -- **Types:** python, pypi (from registry) -- **Verdict:** operational -- **Date:** 2026-07-05 - -## Develop Drift - -`develop` vs `main`: ahead 1, behind 3 (`gh api repos/ptr727/aiopurpleair/compare/main...develop` -> `status: diverged, ahead_by: 1, behind_by: 3`). **Diverged - a drift finding.** `develop` carries one commit `main` lacks (`f4ddc2b` Bump actions-deps #10) and is three behind (`main`'s own `actions-deps` bump `dc59f8f` + merge `3c8001b`, plus `2cacc7b`). Each branch absorbed its own parallel Dependabot `github-actions` bump, so they diverge textually while both stay current - the expected consequence of the parallel dual-target policy under the forward-only model. Because the publisher self-publishes `develop` as a `.dev0` prerelease on its own shipped-path pushes (`publish-release.yml:16-22`), the divergence is not release-inert; reconcile forward-only. - -## Dimensions - -| Dimension | Letter | Intent | Verdict | Evidence (file:line) | -| --- | --- | --- | --- | --- | -| csharp | - | - | N/A | no `*.csproj`/`*.slnx`/`*.cs` in tree (`git ls-files`); `.NET` SDK is pulled only as the NBGV versioning toolchain (`get-version-task.yml:44-47`) | -| nuget | - | - | N/A | no `dotnet nuget push` / NuGet workflow | -| pypi | pass | pass | pass | OIDC Trusted Publishing, no stored token: `publish-pypi` declares `environment: { name: pypi }` (`publish-release.yml:60-62`), `id-token: write` at that one entry point (`:64-66`), `pypa/gh-action-pypi-publish` with `skip-existing: true` (`:76-81`); the `pypi` environment has a deployment-branch policy limiting to `develop`+`main` (live `gh api .../environments/pypi/deployment-branch-policies`). No `PYPI_API_TOKEN`/`TWINE_PASSWORD` anywhere | -| python | pass | pass | pass | ruff and pyright both configured canonically in `pyproject.toml` (`[tool.ruff]` `:68-76`, `[tool.pyright]` `:85-91`); no standalone `.ruff.toml`/`pyrightconfig.json`. Extra `[tool.mypy]` (`:78-83`) is allowed under the strong-typing policy (mypy permitted; hub #245), not a python-config-placement miss | -| console | - | - | N/A | no `build-executable-task.yml` / `System.CommandLine` | -| docker | - | - | N/A | no `Dockerfile` / `build-docker-task.yml` | -| branch-model | drift | pass | drift | both branches protected, live rulesets `enforcement: active` (`gh api .../rulesets`); benign-default-normalized diff of `repo-config/ruleset-{develop,main}.json` vs live is **substantively in sync** (the only diffs are the committed files omitting `bypass_actors` and the `pull_request` `required_reviewers`, which live carries empty - `[]`, no bypass, cleaner than the RepositoryRole-5 cases elsewhere). `main` allows only `merge` (`ruleset-main.json:18`), `develop` only `squash` + `required_linear_history` (`ruleset-develop.json:14,19`); required-check context `Check pull request workflow status job` (`ruleset-main.json:32`) matches the aggregator `name:` (`test-pull-request.yml:47`). Off-baseline filenames - see Drift #5 | -| repo-setup | pass | pass | pass | actions **and** dependabot stores both hold `CODEGEN_APP_CLIENT_ID`, `CODEGEN_APP_PRIVATE_KEY` (merge-bot App, `merge-bot-pull-request.yml:45-46`) and `CODECOV_TOKEN` (codecov feature, `validate-task.yml:87-89`); no forbidden `PYPI_API_TOKEN`/`TWINE_PASSWORD`/`NUGET_API_KEY`/`CODEGEN_APP_ID`. PyPI needs no stored secret (OIDC). Minor over-provision: `CODECOV_TOKEN` also in the dependabot store (spec models codecov `stores: [actions]` only) - harmless, not forbidden | -| linter-parity | pass | pass | pass | one `.markdownlint-cli2.jsonc` (inline `config` + `globs`/`ignores`) and one `cspell.json` drive editor + CLI + CI; CI runs markdownlint (`validate-task.yml:106-109`), cspell (`:112-118`), actionlint (`:120-121`), shellcheck (`:124-125`), ruff (`:39-43`), pyright (`:49-50`); all lang configs canonical in `pyproject.toml` | -| recurring-violations | drift | pass | drift | comments concise; agent-authored prose ASCII-clean (allowed `(c)` in `AGENTS.md:9` and developer warning emoji in `README.md:36`/`HISTORY.md:19`; the non-ASCII in `docs/purpleair-openapi.yaml` is generated external API data - micro/superscript/pi and upstream curly quotes - exempt from normalization); `cspell.json:3` `"language": "en-US"`. **Letter miss (eol):** `build-release-task.yml` and `validate-task.yml` are committed **mixed** CRLF/LF against the repo's own `[*] end_of_line = crlf` default (`.editorconfig:8-14`) - see Drift #2 | -| readme-structure | pass | pass | pass | canonical shape and order per `spec/readme-structure.md`: Title + one-line desc (`README.md:1-3`), `## Build and Distribution` with `### Build Status`/`### Releases`/`### Release Notes` (`:5-38`), `## Table of Contents` (`:40`), Features (`:66`), Installation (`:79`), Getting Started (`:97`), Usage with sub-sections (`:127`), Questions or Issues (`:337`), Contributing (`:346`), Credits/License (`:358,365`); reference-style links grouped at bottom (`:370-402`). Release Notes name the 2-digit `Version 1.0` (`:25`) | -| workflow (WORKFLOW.md 5A/5B) | drift | pass | drift | all applicable D-guarantees hold by **outcome**; divergences are structural (see 5A/5B): path-gated publish-on-merge self-publisher (no schedule, no `PUBLISH_ON_MERGE` variable), no `changes` paths-filter, `github.ref_name` in some branch-derived config, no explicit build-artifact delete (retention backstop), dispatch not fail-fast-guarded (environment-policy backstop), bespoke release attaching `dist/*`+`LICENSE`+`README.md` without the `expect_release_assets` seam | - -## WORKFLOW.md 5A Static Audit (applicable D-guarantees) - -- **D1.1 / D1.4 (paths-filter):** DRIFT - no `changes`/`dorny/paths-filter` job anywhere (`grep` -> none); `validate` and `smoke-build` run on **every** push (`test-pull-request.yml:25-42`), deliberate per the header (`:1-11`). Over-builds docs-only/workflow-only pushes; with a single always-built target the "changed target slips unbuilt" failure cannot occur. **(Recurs across the fleet.)** -- **D1.2 (validation always runs):** PASS - `validate` (the lint + test-matrix + docs gate, this PyPI repo's replacement for `unit-test`) runs unconditionally `if: !github.event.deleted` (`test-pull-request.yml:25-28`); the aggregator `needs:` it (`:49`). Same `validate-task` the publisher runs (`publish-release.yml:34-37`). -- **D1.3 (smoke uploads/pushes nothing):** PASS - the smoke caller passes `smoke: true`, `publish: false` (`test-pull-request.yml:40-42`); `upload-artifact` is gated `if: !inputs.smoke` (`build-release-task.yml:126`); `github-release` is gated `inputs.publish && !inputs.smoke` (`:137`); `publish-pypi` lives only in the publisher, never the PR path. -- **D1.5 (one aggregator):** PASS - `check-workflow-status`, `needs: [validate, smoke-build]`, `always() && !github.event.deleted`, fails on any non-`success` (`test-pull-request.yml:46-62`). Its `name:` is the ruleset-bound context (`:47`, matches `ruleset-{develop,main}.json:32`). -- **D2.1 / D2.2 (validate-at-entry):** PASS - a dedicated `validate-release` entry job (`build-release-task.yml:37-66`) runs before `build` (`build` `needs:` it `:70`), checks **both** directions (`main` must not be prerelease `:58-62`; every other branch must carry a prerelease `-` `:63-66`), strips `+buildmetadata` before testing (`:56-57`), and **skips on smoke** (`:52-55`). This is the full contract - fixes the one-directional embedded-gate drift seen elsewhere. -- **D2.3 (dispatch guard):** DRIFT - the publisher has **no** fail-fast guard rejecting a `workflow_dispatch` from a non-`main`/`develop` ref; the `pypi` environment's deployment-branch policy (live: `develop`+`main` only) blocks the PyPI leg as defense in depth, but `github-release` (`build-release-task.yml:135-169`) carries no branch guard, so a feature-branch dispatch could still cut a stray prerelease GitHub release. Push publishing is correctly branch-scoped (`publish-release.yml:16-17`). WORKFLOW.md D2.3 wants a fail-fast `::error::`. **(Related to the fleet's skip-not-fail dispatch-guard drift.)** -- **D2.4 (paired/XOR inputs):** N/A - no mutually-exclusive input pairs in these workflows. -- **D3.1 (version from checked-out branch):** PASS - `get-version` sets `IGNORE_GITHUB_REF: "true"` (`get-version-task.yml:66`) with `fetch-depth: 0` (`:54`); the `validate-release` gate backstops any misclassification. **Fixes the recurring NBGV-without-IGNORE_GITHUB_REF drift.** -- **D3.2 (default = public, others = prerelease):** PASS - `version.json:4-6` `publicReleaseRefSpec ^refs/heads/main$`; gate literal `REF_NAME == 'main'` (`build-release-task.yml:58`); `.dev0` appended only when `branch == 'develop'` (`:103`); release `prerelease: github.ref_name != 'main'` (`:163`). -- **D3.3 (floor + git height):** PASS - `version.json:3` `version "1.0"`; NBGV appends git height. `version.json` + NBGV retained though the package build backend is hatchling (NBGV owns the release tag/`GitCommitId`). -- **D3.4 (registry version per classification):** DRIFT (letter) / PASS (intent) - the PyPI version is built from **`SemVer2` base** (`Major.Minor.Height`) with `.dev0` on `develop` (`build-release-task.yml:95-109`), **deliberately not `AssemblyFileVersion`** (documented `:92-94`: the 4-part revision would leak a meaningless component). WORKFLOW.md D3.4 names `AssemblyFileVersion (M.N.P.B)` as the source. Intent holds: `develop`'s `X.Y.<height>.dev0` stays `--pre`-selectable and, because `develop`'s git height exceeds `main`'s, sorts above `main`'s latest release. A cleaner 3-part form than the spec's 4-part - see Proposed Updates. -- **D4.1 (two-phase / both branches):** DRIFT (letter) / PASS (intent) - **not** the two-phase model: a shipped-path push to `main`/`develop` **publishes that branch** (`publish-release.yml:16-22`), with **no** `PUBLISH_ON_MERGE` variable gate and **no** weekly schedule; it is a path-gated publish-on-merge self-publisher building one branch per run (`:52`), not a schedule/dispatch matrix over both. Intent (deliberate release, no mechanical churn) holds: the paths list excludes docs/CI/workflow churn (`:18-22`), `skip-existing` dedupes a version-unchanged run, and a merge to `main` is a deliberate promotion. Registry says `two-phase` - correction needed (Proposed Updates). **(Related to the fleet single-branch-publisher drift, distinct self-publish form.)** -- **D4.2 (tag the built commit):** PASS - `target_commitish: needs.get-version.outputs.GitCommitId` (`build-release-task.yml:162`); both `build` and `github-release` check out that same `GitCommitId` (`:80,148`). -- **D4.3 (release contents):** DRIFT (letter) / PASS (intent) - the release is tag + `generate_release_notes` + `dist/*` (wheel + sdist) + `LICENSE` + `README.md` with `fail_on_unmatched_files: true` (`build-release-task.yml:158-169`); `prerelease = github.ref_name != 'main'` (`:163`). It hand-lists files rather than using the template's `expect_release_assets: false` seam (WORKFLOW.md says a PyPI-only release is tag + source zip + README + LICENSE and PyPI contributes no `release-asset-*`); here it additionally attaches the built `dist/*` to the GitHub release. Equivalent-or-richer tag+files outcome; forks the mechanism (bespoke release task, like the docker-only repos). -- **D4.4 (no-op republish):** DRIFT (letter) / PASS (intent) - PyPI dedupes via `skip-existing: true` (`publish-release.yml:81`); the GitHub `Create GitHub release step` is **not** existence-gated (`build-release-task.yml:158-165`), so a re-run/dispatch on an unchanged version would attempt to recreate the tag/release rather than skip it (the template gates the create step `exists == false || workflow_dispatch`). In practice the push trigger fires only on a shipped-path change (new git height -> new version), so a genuine no-op push does not occur; a same-version manual dispatch would error on the existing tag. Registry/PyPI no-op is clean; the GitHub-release no-op guard is absent. -- **D5 (cleanup):** DRIFT (letter) / PASS (intent) - the cross-job transfer artifact `aiopurpleair-build-<branch>` is consumed by both `github-release` (`build-release-task.yml:150-154`) and `publish-pypi` (`publish-release.yml:70-74`) but is **never explicitly deleted** (no `actions: write`, no delete step - `grep` confirms); it relies solely on the `retention-days: 1` backstop (`build-release-task.yml:133`). WORKFLOW.md D5.1 wants `publish-pypi` to consume-then-delete it; the retention backstop covers the intent (no accumulation). No `.artifacts[].id` blanket delete exists (D5.5 PASS); every `upload-artifact` sets `retention-days: 1` (D5.4 PASS). -- **D6.1 (pattern handoff):** N/A/PASS - single build artifact addressed by exact `name:`; no `release-asset-<branch>-*` pattern fan-in (PyPI contributes no release-asset), so no `artifact-ids:` anti-pattern to police. -- **D6.2 (branch drives config):** DRIFT - the PyPI-version compute correctly reads `inputs.branch` (`build-release-task.yml:99,103`), but the `validate-release` gate reads `github.ref_name` (`:45,58`) and the release `prerelease` reads `github.ref_name` (`:163`). Safe here because the caller passes `branch: github.ref_name` and one branch is built per run, so they are always equal; a latent letter miss (inconsistent with the `inputs.branch` rule) that a future matrix would break. -- **D6.3 (branch-suffixed artifacts):** PASS - `aiopurpleair-build-${{ inputs.branch }}` (`build-release-task.yml:129`). -- **D6.4 (target add/drop consistent):** N/A - single PyPI target; no multi-target `enable_*`/paths-filter surface. -- **D7.1 (publisher serializes):** PASS - publisher concurrency group `${{ github.workflow }}` (ref-independent) with `cancel-in-progress: false` (`publish-release.yml:27-29`). -- **D7.2 (permissions):** PASS - the `build` caller grants `contents: write` at the one entry point (`publish-release.yml:47-48`), the reusable `build-release-task` declares none (so the read-only smoke caller does not over-grant, `test-pull-request.yml:37-38`), and `id-token: write` appears only on `publish-pypi` (`publish-release.yml:64-66`). -- **D7.3 / D7.4 (booleans / optional-dep chaining):** PASS - booleans (`publish`/`smoke`) are `workflow_call`-only, so both-form comparison is N/A; `check-workflow-status` uses explicit per-result `success` checks (`test-pull-request.yml:55-61`). -- **D8.1 (merge-bot):** PASS - auto-merge on `opened`/`reopened` for in-repo Dependabot PRs (`merge-bot-pull-request.yml:31-34`), method by base (`develop` -> `--squash`, `main` -> `--merge`, unknown -> `::error::` `:51-55`); disable on maintainer `synchronize` (`:66-70`); concurrency keyed on the PR number (`:20-22`); App token via `client-id` so merges retrigger downstream workflows (`:43-46`). -- **D8.2 (Dependabot):** PASS - `uv` and `github-actions` each on both `main` and `develop`, daily, grouped (`dependabot.yml:17-57`); CVE security PRs to `main` handled by the same merge-bot `case` (`:9-11` note). -- **D8.3 (upstream tracker):** N/A - not a wrapper (no `upstream-version.json`/`check-upstream-version-task.yml`; the OpenAPI regen `scripts/generate_openapi.py` is a manual `uv run`, not a scheduled bump PR). -- **D9.1 (SHA-pinning):** PASS - all actions SHA-pinned with `# vX.Y.Z` comments; `dotnet/nbgv@master` is the sanctioned lagging-tag exception (`get-version-task.yml:58-60`, documented `:56-57`). -- **D9.5 (line endings):** DRIFT - `build-release-task.yml` and `validate-task.yml` committed mixed - see Drift #2. -- **NuGet / console / docker 5A addenda:** N/A. - -## WORKFLOW.md 5B Trace (applicable scenarios) - -- **S1** (PR touches the library): `validate` (lint+test) + `smoke-build` (branch config, `smoke:true`) run; `validate-release` skipped (smoke `:52-55`) and succeeds; no upload (`:126`), no publish; aggregator success; version = prerelease off a feature branch. PASS. -- **S2 / S3** (docs-only / workflow-only PR): `validate` + `smoke-build` **run** (no paths-filter) instead of skipping; aggregator success - safe but over-builds. DRIFT. -- **S4** (PR base = `main`): smoke versions as prerelease; `validate-release` skips on smoke (`build-release-task.yml:52-55`) so the `main` arm does not fire; promotion not blocked; aggregator success. PASS. -- **S5** (push to a non-publishable branch, no shipped-path change): `publish-release` does not trigger (push is limited to `main`/`develop` with a paths list, `publish-release.yml:16-22`); nothing publishes. PASS (stronger than opt-in-variable gating). -- **S6** (shipped-path push to `develop`): publishes a `.dev0` prerelease + GitHub prerelease. This is the self-publish model (publishes without a `PUBLISH_ON_MERGE` opt-in). DRIFT (model), outcome coherent. -- **S7** (scheduled/dispatched publish): **no schedule exists**; a `workflow_dispatch` builds **only** the trigger branch (not a both-branch matrix): from `main` -> stable `X.Y.Z`, `prerelease=false`, PyPI + GitHub release; from `develop` -> `X.Y.Z.dev0`, `prerelease=true`. Build artifact consumed by both `github-release` and `publish-pypi`, then reaped by retention (no explicit delete). DRIFT (single-branch, no schedule, no delete); leg outcomes correct. -- **S8** (dispatch from a non-`main`/`develop` ref): no fail-fast guard; the `pypi` environment deployment-branch policy blocks the PyPI upload, but `github-release` would still cut a stray prerelease. DRIFT (template fails fast). -- **S9** (re-run/dispatch, version unchanged): PyPI is a `skip-existing` no-op (`publish-release.yml:81`); the GitHub `Create GitHub release step` is **not** existence-gated (`build-release-task.yml:158`), so a same-version dispatch would error on the existing tag rather than skip. DRIFT (missing D4.4 release-create guard); the push path never hits this (a shipped-path change advances the version). -- **S10** (branch/version disagree): `validate-release` fails loud in both directions (`build-release-task.yml:58-66`). PASS. -- **S11** (wrapper bump): N/A - not a wrapper. - -## Defects (most severe first) - -None. No applicable check fails both letter and intent, so the repo is **operational**. - -## Drift Findings - -1. **Release model is a path-gated publish-on-merge self-publisher, not two-phase.** A shipped-path push to `main`/`develop` publishes that branch (`publish-release.yml:16-22,52`), with no `PUBLISH_ON_MERGE` variable and no weekly schedule (WORKFLOW.md D4.1's two-phase default: merges do not publish unless the opt-in variable is set; schedule+dispatch publish both branches via a matrix). Intent holds - the paths list confines publishing to functional changes and `skip-existing` dedupes - but the mechanism diverges and the registry `releaseTrigger` is wrong (`two-phase`). The most consequential divergence; see Proposed Updates. **(Related to the fleet single-branch-publisher drift, distinct self-publish form.)** -2. **Two workflow files are committed with mixed line endings, violating the repo's own CRLF default.** `build-release-task.yml:83` and `validate-task.yml:30,68,107` are LF-only lines - exactly the `uses: <action>@<sha> # vX` action-pin lines a bot/editor rewrites - inside otherwise-CRLF files (`git ls-files --eol` -> `i/mixed` for both, `i/crlf` for every other workflow). `.editorconfig:8-14` sets `[*] end_of_line = crlf` (this repo has the global default the fleet flags others for missing), so the default is not the cause: `.gitattributes:7 * -text` disables renormalization, so an LF-writing action-pin edit lands mixed. No runtime impact (Actions parses either), but a hygiene miss and diff-churn risk. **(Recurs with the vscode-server mixed-`uses:` case - here despite a correct global EOL default, isolating `* -text` as the root cause.)** -3. **The repo documents its mypy usage in project rewrites of its carried governance docs.** mypy is now permitted by the strong-typing policy (hub #245), so adopting it is no longer drift; the remaining drift is the doc rewrite (see #10).**Detail:** `pyproject.toml:78-83` configures `[tool.mypy]` (strict, pydantic plugin); `validate-task.yml:46-47` runs `mypy src` as a CI gate; `.vscode/tasks.json` names it in the clean-compile task ("Lint: ruff + mypy + pyright (verify)"). The fleet `CODESTYLE.md` states "mypy is not used here - don't introduce it" - but the repo's own `CODESTYLE.md` (`:50,55,74,118`) and `WORKFLOW.md` are project-rewrites (not the fleet whole-file carries `spec/files.json` expects) that document mypy as a deliberate second type gate. Python config-placement (ruff+pyright in `pyproject.toml`) still passes; the remaining drift is the doc divergence (see #10). The mypy question is resolved - see Proposed Updates. -4. **No `changes` paths-filter; every push smoke-builds.** `test-pull-request.yml:25-42` runs `validate` + `smoke-build` on every push including docs-only and workflow-only. Single always-built target, so no target-slips-unbuilt risk. **(Recurs across the fleet.)** -5. **`repo-config` ruleset filenames off-baseline; committed rulesets omit defaulted fields.** Committed as `repo-config/ruleset-{develop,main}.json`; `spec/files.json:15-16` expects `repo-config/develop.json`/`main.json`. The committed payloads omit top-level `bypass_actors` and the `pull_request` `required_reviewers`; live carries both empty (`bypass_actors: []`, `required_reviewers: []`), so the normalized diff is substantively in sync - benign (cleaner than the RepositoryRole-5 cases in other repos). **(Filenames recur with Utilities/PlexCleaner/LanguageTags/ESPHome-NonRoot; omitted-fields recur with LanguageTags/ESPHome-NonRoot.)** -6. **`github.ref_name` used in some branch-derived config where `inputs.branch` is the rule.** `build-release-task.yml:45,58` (`validate-release`) and `:163` (release `prerelease`) read `github.ref_name`; the PyPI-version compute correctly reads `inputs.branch` (`:99,103`). Equal here (one branch per run, caller passes `branch: github.ref_name`); a latent WORKFLOW.md D6.2 letter miss and an internal inconsistency. -7. **Bespoke release task without the `expect_release_assets` seam.** `build-release-task.yml:158-169` hand-lists `dist/*`+`LICENSE`+`README.md` with `fail_on_unmatched_files: true` and no existence gate on the create step (D4.4), rather than the template's `expect_release_assets: false` + existence-gated create. Equivalent tag+files outcome; forks the mechanism. -8. **Build artifact relies on the retention backstop, not a consume-then-delete.** `aiopurpleair-build-<branch>` (`build-release-task.yml:125-133`) is consumed by `github-release` and `publish-pypi` but never explicitly deleted (no `actions: write`); WORKFLOW.md D5.1 wants `publish-pypi` to delete it. `retention-days: 1` covers the intent. -9. **Dispatch not fail-fast-guarded.** No `::error::` guard rejects a `workflow_dispatch` from a non-`main`/`develop` ref (`publish-release.yml`); the `pypi` environment deployment-branch policy backstops the PyPI leg, but `github-release` is unguarded. WORKFLOW.md D2.3 wants fail-fast. -10. **Governance docs are project-tailored rewrites rather than fleet whole-file carries.** `AGENTS.md` reshapes the baseline sections (`spec/files.json:5`) into project-named ones (e.g. "Branches and merging" for Branching Model, "Writing style" for Documentation Style Conventions, "Reviewing CI / Release-Train Changes" for Workflow YAML Conventions); `CODESTYLE.md`/`WORKFLOW.md` are likewise localized. Intent (the rules exist and are internally consistent) holds; diverges from the standardization baseline. `AGENTS.md` is legitimately project-specific per the fleet model; `CODESTYLE.md`/`WORKFLOW.md` are `whole: true` in the spec, so their rewrites are the sharper drift (and the vehicle for finding #3). -11. **`develop` diverged from `main`** (ahead 1, behind 3). See Develop Drift. - -## Proposed Registry / Spec Updates - -- **Registry correction (release model).** `registry/repos.json` records `releaseTrigger: two-phase` for aiopurpleair, but the repo runs a path-gated **publish-on-merge self-publisher** (push to `main`/`develop` on shipped paths + dispatch, no schedule, no `PUBLISH_ON_MERGE` variable, one branch per run). Update the entry to reflect the continuous/publish-on-merge model, or (if two-phase is the intended target state) file convergence to add the schedule+matrix+opt-in gate. This is the primary finding. -- **Mypy question - resolved by hub #245.** aiopurpleair runs mypy as a second type gate alongside pyright; the strong-typing policy now permits mypy as an additional checker (required for HA platinum, useful for the `pydantic.mypy` plugin), so this is no longer drift. The remaining divergence is that this repo's `CODESTYLE.md`/`WORKFLOW.md` are project rewrites rather than the fleet whole-file carries (finding #10). -- **Spec signal (PyPI version source).** WORKFLOW.md D3.4 mandates `AssemblyFileVersion (M.N.P.B)` as the PyPI version base; this repo deliberately derives it from `SemVer2` (3-part `Major.Minor.Height`) + `.dev0`, arguing the 4-part revision leaks a meaningless component (`build-release-task.yml:92-94`). The 3-part form still satisfies the `--pre`-selectable + sorts-above-release intent. If this reasoning generalizes, the spec should bless the `SemVer2`-base form (or explain why the 4-part is required). -- **Spec signal (EOL root cause).** A second repo shows mixed-ending workflow files from LF-written `uses:` action-pin lines - here **despite** a correct global `[*] end_of_line = crlf` default, isolating `.gitattributes: * -text` (no renormalization) as the true root cause. Strengthens the case for a machine check that scans each tracked file's committed endings per-line against `.editorconfig` (a whole-file check misses this), and possibly for a narrow `.gitattributes` `text=auto eol=crlf` pin on `.github/workflows/**`. -- **Recurring cross-fleet drifts** present here too (off-baseline `repo-config/ruleset-*.json` filenames, committed rulesets omitting `bypass_actors`/`required_reviewers`, no `changes` paths-filter, non-fail-fast dispatch guard). Notably **absent** (already fixed): the missing global EOL default, NBGV without `IGNORE_GITHUB_REF`, and the one-directional embedded release gate - aiopurpleair carries the corrected forms of all three. +# Audit: aiopurpleair + +- **Audited branch:** main (`3c8001b16b091cc20c881531638946b8a3aa9c9b`) +- **Types:** python, pypi (from registry) +- **Verdict:** operational +- **Date:** 2026-07-05 + +## Develop Drift + +`develop` vs `main`: ahead 1, behind 3 (`gh api repos/ptr727/aiopurpleair/compare/main...develop` -> `status: diverged, ahead_by: 1, behind_by: 3`). **Diverged - a drift finding.** `develop` carries one commit `main` lacks (`f4ddc2b` Bump actions-deps #10) and is three behind (`main`'s own `actions-deps` bump `dc59f8f` + merge `3c8001b`, plus `2cacc7b`). Each branch absorbed its own parallel Dependabot `github-actions` bump, so they diverge textually while both stay current - the expected consequence of the parallel dual-target policy under the forward-only model. Because the publisher self-publishes `develop` as a `.dev0` prerelease on its own shipped-path pushes (`publish-release.yml:16-22`), the divergence is not release-inert; reconcile forward-only. + +## Dimensions + +| Dimension | Letter | Intent | Verdict | Evidence (file:line) | +| --- | --- | --- | --- | --- | +| csharp | - | - | N/A | no `*.csproj`/`*.slnx`/`*.cs` in tree (`git ls-files`); `.NET` SDK is pulled only as the NBGV versioning toolchain (`get-version-task.yml:44-47`) | +| nuget | - | - | N/A | no `dotnet nuget push` / NuGet workflow | +| pypi | pass | pass | pass | OIDC Trusted Publishing, no stored token: `publish-pypi` declares `environment: { name: pypi }` (`publish-release.yml:60-62`), `id-token: write` at that one entry point (`:64-66`), `pypa/gh-action-pypi-publish` with `skip-existing: true` (`:76-81`); the `pypi` environment has a deployment-branch policy limiting to `develop`+`main` (live `gh api .../environments/pypi/deployment-branch-policies`). No `PYPI_API_TOKEN`/`TWINE_PASSWORD` anywhere | +| python | pass | pass | pass | ruff and pyright both configured canonically in `pyproject.toml` (`[tool.ruff]` `:68-76`, `[tool.pyright]` `:85-91`); no standalone `.ruff.toml`/`pyrightconfig.json`. Extra `[tool.mypy]` (`:78-83`) is allowed under the strong-typing policy (mypy permitted; hub #245), not a python-config-placement miss | +| console | - | - | N/A | no `build-executable-task.yml` / `System.CommandLine` | +| docker | - | - | N/A | no `Dockerfile` / `build-docker-task.yml` | +| branch-model | drift | pass | drift | both branches protected, live rulesets `enforcement: active` (`gh api .../rulesets`); benign-default-normalized diff of `repo-config/ruleset-{develop,main}.json` vs live is **substantively in sync** (the only diffs are the committed files omitting `bypass_actors` and the `pull_request` `required_reviewers`, which live carries empty - `[]`, no bypass, cleaner than the RepositoryRole-5 cases elsewhere). `main` allows only `merge` (`ruleset-main.json:18`), `develop` only `squash` + `required_linear_history` (`ruleset-develop.json:14,19`); required-check context `Check pull request workflow status job` (`ruleset-main.json:32`) matches the aggregator `name:` (`test-pull-request.yml:47`). Off-baseline filenames - see Drift #5 | +| repo-setup | pass | pass | pass | actions **and** dependabot stores both hold `CODEGEN_APP_CLIENT_ID`, `CODEGEN_APP_PRIVATE_KEY` (merge-bot App, `merge-bot-pull-request.yml:45-46`) and `CODECOV_TOKEN` (codecov feature, `validate-task.yml:87-89`); no forbidden `PYPI_API_TOKEN`/`TWINE_PASSWORD`/`NUGET_API_KEY`/`CODEGEN_APP_ID`. PyPI needs no stored secret (OIDC). Minor over-provision: `CODECOV_TOKEN` also in the dependabot store (spec models codecov `stores: [actions]` only) - harmless, not forbidden | +| linter-parity | pass | pass | pass | one `.markdownlint-cli2.jsonc` (inline `config` + `globs`/`ignores`) and one `cspell.json` drive editor + CLI + CI; CI runs markdownlint (`validate-task.yml:106-109`), cspell (`:112-118`), actionlint (`:120-121`), shellcheck (`:124-125`), ruff (`:39-43`), pyright (`:49-50`); all lang configs canonical in `pyproject.toml` | +| recurring-violations | drift | pass | drift | comments concise; agent-authored prose ASCII-clean (allowed `(c)` in `AGENTS.md:9` and developer warning emoji in `README.md:36`/`HISTORY.md:19`; the non-ASCII in `docs/purpleair-openapi.yaml` is generated external API data - micro/superscript/pi and upstream curly quotes - exempt from normalization); `cspell.json:3` `"language": "en-US"`. **Letter miss (eol):** `build-release-task.yml` and `validate-task.yml` are committed **mixed** CRLF/LF against the repo's own `[*] end_of_line = crlf` default (`.editorconfig:8-14`) - see Drift #2 | +| readme-structure | pass | pass | pass | canonical shape and order per `spec/readme-structure.md`: Title + one-line desc (`README.md:1-3`), `## Build and Distribution` with `### Build Status`/`### Releases`/`### Release Notes` (`:5-38`), `## Table of Contents` (`:40`), Features (`:66`), Installation (`:79`), Getting Started (`:97`), Usage with sub-sections (`:127`), Questions or Issues (`:337`), Contributing (`:346`), Credits/License (`:358,365`); reference-style links grouped at bottom (`:370-402`). Release Notes name the 2-digit `Version 1.0` (`:25`) | +| workflow (WORKFLOW.md 5A/5B) | drift | pass | drift | all applicable D-guarantees hold by **outcome**; divergences are structural (see 5A/5B): path-gated publish-on-merge self-publisher (no schedule, no `PUBLISH_ON_MERGE` variable), no `changes` paths-filter, `github.ref_name` in some branch-derived config, no explicit build-artifact delete (retention backstop), dispatch not fail-fast-guarded (environment-policy backstop), bespoke release attaching `dist/*`+`LICENSE`+`README.md` without the `expect_release_assets` seam | + +## WORKFLOW.md 5A Static Audit (applicable D-guarantees) + +- **D1.1 / D1.4 (paths-filter):** DRIFT - no `changes`/`dorny/paths-filter` job anywhere (`grep` -> none); `validate` and `smoke-build` run on **every** push (`test-pull-request.yml:25-42`), deliberate per the header (`:1-11`). Over-builds docs-only/workflow-only pushes; with a single always-built target the "changed target slips unbuilt" failure cannot occur. **(Recurs across the fleet.)** +- **D1.2 (validation always runs):** PASS - `validate` (the lint + test-matrix + docs gate, this PyPI repo's replacement for `unit-test`) runs unconditionally `if: !github.event.deleted` (`test-pull-request.yml:25-28`); the aggregator `needs:` it (`:49`). Same `validate-task` the publisher runs (`publish-release.yml:34-37`). +- **D1.3 (smoke uploads/pushes nothing):** PASS - the smoke caller passes `smoke: true`, `publish: false` (`test-pull-request.yml:40-42`); `upload-artifact` is gated `if: !inputs.smoke` (`build-release-task.yml:126`); `github-release` is gated `inputs.publish && !inputs.smoke` (`:137`); `publish-pypi` lives only in the publisher, never the PR path. +- **D1.5 (one aggregator):** PASS - `check-workflow-status`, `needs: [validate, smoke-build]`, `always() && !github.event.deleted`, fails on any non-`success` (`test-pull-request.yml:46-62`). Its `name:` is the ruleset-bound context (`:47`, matches `ruleset-{develop,main}.json:32`). +- **D2.1 / D2.2 (validate-at-entry):** PASS - a dedicated `validate-release` entry job (`build-release-task.yml:37-66`) runs before `build` (`build` `needs:` it `:70`), checks **both** directions (`main` must not be prerelease `:58-62`; every other branch must carry a prerelease `-` `:63-66`), strips `+buildmetadata` before testing (`:56-57`), and **skips on smoke** (`:52-55`). This is the full contract - fixes the one-directional embedded-gate drift seen elsewhere. +- **D2.3 (dispatch guard):** DRIFT - the publisher has **no** fail-fast guard rejecting a `workflow_dispatch` from a non-`main`/`develop` ref; the `pypi` environment's deployment-branch policy (live: `develop`+`main` only) blocks the PyPI leg as defense in depth, but `github-release` (`build-release-task.yml:135-169`) carries no branch guard, so a feature-branch dispatch could still cut a stray prerelease GitHub release. Push publishing is correctly branch-scoped (`publish-release.yml:16-17`). WORKFLOW.md D2.3 wants a fail-fast `::error::`. **(Related to the fleet's skip-not-fail dispatch-guard drift.)** +- **D2.4 (paired/XOR inputs):** N/A - no mutually-exclusive input pairs in these workflows. +- **D3.1 (version from checked-out branch):** PASS - `get-version` sets `IGNORE_GITHUB_REF: "true"` (`get-version-task.yml:66`) with `fetch-depth: 0` (`:54`); the `validate-release` gate backstops any misclassification. **Fixes the recurring NBGV-without-IGNORE_GITHUB_REF drift.** +- **D3.2 (default = public, others = prerelease):** PASS - `version.json:4-6` `publicReleaseRefSpec ^refs/heads/main$`; gate literal `REF_NAME == 'main'` (`build-release-task.yml:58`); `.dev0` appended only when `branch == 'develop'` (`:103`); release `prerelease: github.ref_name != 'main'` (`:163`). +- **D3.3 (floor + git height):** PASS - `version.json:3` `version "1.0"`; NBGV appends git height. `version.json` + NBGV retained though the package build backend is hatchling (NBGV owns the release tag/`GitCommitId`). +- **D3.4 (registry version per classification):** DRIFT (letter) / PASS (intent) - the PyPI version is built from **`SemVer2` base** (`Major.Minor.Height`) with `.dev0` on `develop` (`build-release-task.yml:95-109`), **deliberately not `AssemblyFileVersion`** (documented `:92-94`: the 4-part revision would leak a meaningless component). WORKFLOW.md D3.4 names `AssemblyFileVersion (M.N.P.B)` as the source. Intent holds: `develop`'s `X.Y.<height>.dev0` stays `--pre`-selectable and, because `develop`'s git height exceeds `main`'s, sorts above `main`'s latest release. A cleaner 3-part form than the spec's 4-part - see Proposed Updates. +- **D4.1 (two-phase / both branches):** DRIFT (letter) / PASS (intent) - **not** the two-phase model: a shipped-path push to `main`/`develop` **publishes that branch** (`publish-release.yml:16-22`), with **no** `PUBLISH_ON_MERGE` variable gate and **no** weekly schedule; it is a path-gated publish-on-merge self-publisher building one branch per run (`:52`), not a schedule/dispatch matrix over both. Intent (deliberate release, no mechanical churn) holds: the paths list excludes docs/CI/workflow churn (`:18-22`), `skip-existing` dedupes a version-unchanged run, and a merge to `main` is a deliberate promotion. Registry says `two-phase` - correction needed (Proposed Updates). **(Related to the fleet single-branch-publisher drift, distinct self-publish form.)** +- **D4.2 (tag the built commit):** PASS - `target_commitish: needs.get-version.outputs.GitCommitId` (`build-release-task.yml:162`); both `build` and `github-release` check out that same `GitCommitId` (`:80,148`). +- **D4.3 (release contents):** DRIFT (letter) / PASS (intent) - the release is tag + `generate_release_notes` + `dist/*` (wheel + sdist) + `LICENSE` + `README.md` with `fail_on_unmatched_files: true` (`build-release-task.yml:158-169`); `prerelease = github.ref_name != 'main'` (`:163`). It hand-lists files rather than using the template's `expect_release_assets: false` seam (WORKFLOW.md says a PyPI-only release is tag + source zip + README + LICENSE and PyPI contributes no `release-asset-*`); here it additionally attaches the built `dist/*` to the GitHub release. Equivalent-or-richer tag+files outcome; forks the mechanism (bespoke release task, like the docker-only repos). +- **D4.4 (no-op republish):** DRIFT (letter) / PASS (intent) - PyPI dedupes via `skip-existing: true` (`publish-release.yml:81`); the GitHub `Create GitHub release step` is **not** existence-gated (`build-release-task.yml:158-165`), so a re-run/dispatch on an unchanged version would attempt to recreate the tag/release rather than skip it (the template gates the create step `exists == false || workflow_dispatch`). In practice the push trigger fires only on a shipped-path change (new git height -> new version), so a genuine no-op push does not occur; a same-version manual dispatch would error on the existing tag. Registry/PyPI no-op is clean; the GitHub-release no-op guard is absent. +- **D5 (cleanup):** DRIFT (letter) / PASS (intent) - the cross-job transfer artifact `aiopurpleair-build-<branch>` is consumed by both `github-release` (`build-release-task.yml:150-154`) and `publish-pypi` (`publish-release.yml:70-74`) but is **never explicitly deleted** (no `actions: write`, no delete step - `grep` confirms); it relies solely on the `retention-days: 1` backstop (`build-release-task.yml:133`). WORKFLOW.md D5.1 wants `publish-pypi` to consume-then-delete it; the retention backstop covers the intent (no accumulation). No `.artifacts[].id` blanket delete exists (D5.5 PASS); every `upload-artifact` sets `retention-days: 1` (D5.4 PASS). +- **D6.1 (pattern handoff):** N/A/PASS - single build artifact addressed by exact `name:`; no `release-asset-<branch>-*` pattern fan-in (PyPI contributes no release-asset), so no `artifact-ids:` anti-pattern to police. +- **D6.2 (branch drives config):** DRIFT - the PyPI-version compute correctly reads `inputs.branch` (`build-release-task.yml:99,103`), but the `validate-release` gate reads `github.ref_name` (`:45,58`) and the release `prerelease` reads `github.ref_name` (`:163`). Safe here because the caller passes `branch: github.ref_name` and one branch is built per run, so they are always equal; a latent letter miss (inconsistent with the `inputs.branch` rule) that a future matrix would break. +- **D6.3 (branch-suffixed artifacts):** PASS - `aiopurpleair-build-${{ inputs.branch }}` (`build-release-task.yml:129`). +- **D6.4 (target add/drop consistent):** N/A - single PyPI target; no multi-target `enable_*`/paths-filter surface. +- **D7.1 (publisher serializes):** PASS - publisher concurrency group `${{ github.workflow }}` (ref-independent) with `cancel-in-progress: false` (`publish-release.yml:27-29`). +- **D7.2 (permissions):** PASS - the `build` caller grants `contents: write` at the one entry point (`publish-release.yml:47-48`), the reusable `build-release-task` declares none (so the read-only smoke caller does not over-grant, `test-pull-request.yml:37-38`), and `id-token: write` appears only on `publish-pypi` (`publish-release.yml:64-66`). +- **D7.3 / D7.4 (booleans / optional-dep chaining):** PASS - booleans (`publish`/`smoke`) are `workflow_call`-only, so both-form comparison is N/A; `check-workflow-status` uses explicit per-result `success` checks (`test-pull-request.yml:55-61`). +- **D8.1 (merge-bot):** PASS - auto-merge on `opened`/`reopened` for in-repo Dependabot PRs (`merge-bot-pull-request.yml:31-34`), method by base (`develop` -> `--squash`, `main` -> `--merge`, unknown -> `::error::` `:51-55`); disable on maintainer `synchronize` (`:66-70`); concurrency keyed on the PR number (`:20-22`); App token via `client-id` so merges retrigger downstream workflows (`:43-46`). +- **D8.2 (Dependabot):** PASS - `uv` and `github-actions` each on both `main` and `develop`, daily, grouped (`dependabot.yml:17-57`); CVE security PRs to `main` handled by the same merge-bot `case` (`:9-11` note). +- **D8.3 (upstream tracker):** N/A - not a wrapper (no `upstream-version.json`/`check-upstream-version-task.yml`; the OpenAPI regen `scripts/generate_openapi.py` is a manual `uv run`, not a scheduled bump PR). +- **D9.1 (SHA-pinning):** PASS - all actions SHA-pinned with `# vX.Y.Z` comments; `dotnet/nbgv@master` is the sanctioned lagging-tag exception (`get-version-task.yml:58-60`, documented `:56-57`). +- **D9.5 (line endings):** DRIFT - `build-release-task.yml` and `validate-task.yml` committed mixed - see Drift #2. +- **NuGet / console / docker 5A addenda:** N/A. + +## WORKFLOW.md 5B Trace (applicable scenarios) + +- **S1** (PR touches the library): `validate` (lint+test) + `smoke-build` (branch config, `smoke:true`) run; `validate-release` skipped (smoke `:52-55`) and succeeds; no upload (`:126`), no publish; aggregator success; version = prerelease off a feature branch. PASS. +- **S2 / S3** (docs-only / workflow-only PR): `validate` + `smoke-build` **run** (no paths-filter) instead of skipping; aggregator success - safe but over-builds. DRIFT. +- **S4** (PR base = `main`): smoke versions as prerelease; `validate-release` skips on smoke (`build-release-task.yml:52-55`) so the `main` arm does not fire; promotion not blocked; aggregator success. PASS. +- **S5** (push to a non-publishable branch, no shipped-path change): `publish-release` does not trigger (push is limited to `main`/`develop` with a paths list, `publish-release.yml:16-22`); nothing publishes. PASS (stronger than opt-in-variable gating). +- **S6** (shipped-path push to `develop`): publishes a `.dev0` prerelease + GitHub prerelease. This is the self-publish model (publishes without a `PUBLISH_ON_MERGE` opt-in). DRIFT (model), outcome coherent. +- **S7** (scheduled/dispatched publish): **no schedule exists**; a `workflow_dispatch` builds **only** the trigger branch (not a both-branch matrix): from `main` -> stable `X.Y.Z`, `prerelease=false`, PyPI + GitHub release; from `develop` -> `X.Y.Z.dev0`, `prerelease=true`. Build artifact consumed by both `github-release` and `publish-pypi`, then reaped by retention (no explicit delete). DRIFT (single-branch, no schedule, no delete); leg outcomes correct. +- **S8** (dispatch from a non-`main`/`develop` ref): no fail-fast guard; the `pypi` environment deployment-branch policy blocks the PyPI upload, but `github-release` would still cut a stray prerelease. DRIFT (template fails fast). +- **S9** (re-run/dispatch, version unchanged): PyPI is a `skip-existing` no-op (`publish-release.yml:81`); the GitHub `Create GitHub release step` is **not** existence-gated (`build-release-task.yml:158`), so a same-version dispatch would error on the existing tag rather than skip. DRIFT (missing D4.4 release-create guard); the push path never hits this (a shipped-path change advances the version). +- **S10** (branch/version disagree): `validate-release` fails loud in both directions (`build-release-task.yml:58-66`). PASS. +- **S11** (wrapper bump): N/A - not a wrapper. + +## Defects (most severe first) + +None. No applicable check fails both letter and intent, so the repo is **operational**. + +## Drift Findings + +1. **Release model is a path-gated publish-on-merge self-publisher, not two-phase.** A shipped-path push to `main`/`develop` publishes that branch (`publish-release.yml:16-22,52`), with no `PUBLISH_ON_MERGE` variable and no weekly schedule (WORKFLOW.md D4.1's two-phase default: merges do not publish unless the opt-in variable is set; schedule+dispatch publish both branches via a matrix). Intent holds - the paths list confines publishing to functional changes and `skip-existing` dedupes - but the mechanism diverges and the registry `releaseTrigger` is wrong (`two-phase`). The most consequential divergence; see Proposed Updates. **(Related to the fleet single-branch-publisher drift, distinct self-publish form.)** +2. **Two workflow files are committed with mixed line endings, violating the repo's own CRLF default.** `build-release-task.yml:83` and `validate-task.yml:30,68,107` are LF-only lines - exactly the `uses: <action>@<sha> # vX` action-pin lines a bot/editor rewrites - inside otherwise-CRLF files (`git ls-files --eol` -> `i/mixed` for both, `i/crlf` for every other workflow). `.editorconfig:8-14` sets `[*] end_of_line = crlf` (this repo has the global default the fleet flags others for missing), so the default is not the cause: `.gitattributes:7 * -text` disables renormalization, so an LF-writing action-pin edit lands mixed. No runtime impact (Actions parses either), but a hygiene miss and diff-churn risk. **(Recurs with the vscode-server mixed-`uses:` case - here despite a correct global EOL default, isolating `* -text` as the root cause.)** +3. **The repo documents its mypy usage in project rewrites of its carried governance docs.** mypy is now permitted by the strong-typing policy (hub #245), so adopting it is no longer drift; the remaining drift is the doc rewrite (see #10).**Detail:** `pyproject.toml:78-83` configures `[tool.mypy]` (strict, pydantic plugin); `validate-task.yml:46-47` runs `mypy src` as a CI gate; `.vscode/tasks.json` names it in the clean-compile task ("Lint: ruff + mypy + pyright (verify)"). The fleet `CODESTYLE.md` states "mypy is not used here - don't introduce it" - but the repo's own `CODESTYLE.md` (`:50,55,74,118`) and `WORKFLOW.md` are project-rewrites (not the fleet whole-file carries `spec/files.json` expects) that document mypy as a deliberate second type gate. Python config-placement (ruff+pyright in `pyproject.toml`) still passes; the remaining drift is the doc divergence (see #10). The mypy question is resolved - see Proposed Updates. +4. **No `changes` paths-filter; every push smoke-builds.** `test-pull-request.yml:25-42` runs `validate` + `smoke-build` on every push including docs-only and workflow-only. Single always-built target, so no target-slips-unbuilt risk. **(Recurs across the fleet.)** +5. **`repo-config` ruleset filenames off-baseline; committed rulesets omit defaulted fields.** Committed as `repo-config/ruleset-{develop,main}.json`; `spec/files.json:15-16` expects `repo-config/develop.json`/`main.json`. The committed payloads omit top-level `bypass_actors` and the `pull_request` `required_reviewers`; live carries both empty (`bypass_actors: []`, `required_reviewers: []`), so the normalized diff is substantively in sync - benign (cleaner than the RepositoryRole-5 cases in other repos). **(Filenames recur with Utilities/PlexCleaner/LanguageTags/ESPHome-NonRoot; omitted-fields recur with LanguageTags/ESPHome-NonRoot.)** +6. **`github.ref_name` used in some branch-derived config where `inputs.branch` is the rule.** `build-release-task.yml:45,58` (`validate-release`) and `:163` (release `prerelease`) read `github.ref_name`; the PyPI-version compute correctly reads `inputs.branch` (`:99,103`). Equal here (one branch per run, caller passes `branch: github.ref_name`); a latent WORKFLOW.md D6.2 letter miss and an internal inconsistency. +7. **Bespoke release task without the `expect_release_assets` seam.** `build-release-task.yml:158-169` hand-lists `dist/*`+`LICENSE`+`README.md` with `fail_on_unmatched_files: true` and no existence gate on the create step (D4.4), rather than the template's `expect_release_assets: false` + existence-gated create. Equivalent tag+files outcome; forks the mechanism. +8. **Build artifact relies on the retention backstop, not a consume-then-delete.** `aiopurpleair-build-<branch>` (`build-release-task.yml:125-133`) is consumed by `github-release` and `publish-pypi` but never explicitly deleted (no `actions: write`); WORKFLOW.md D5.1 wants `publish-pypi` to delete it. `retention-days: 1` covers the intent. +9. **Dispatch not fail-fast-guarded.** No `::error::` guard rejects a `workflow_dispatch` from a non-`main`/`develop` ref (`publish-release.yml`); the `pypi` environment deployment-branch policy backstops the PyPI leg, but `github-release` is unguarded. WORKFLOW.md D2.3 wants fail-fast. +10. **Governance docs are project-tailored rewrites rather than fleet whole-file carries.** `AGENTS.md` reshapes the baseline sections (`spec/files.json:5`) into project-named ones (e.g. "Branches and merging" for Branching Model, "Writing style" for Documentation Style Conventions, "Reviewing CI / Release-Train Changes" for Workflow YAML Conventions); `CODESTYLE.md`/`WORKFLOW.md` are likewise localized. Intent (the rules exist and are internally consistent) holds; diverges from the standardization baseline. `AGENTS.md` is legitimately project-specific per the fleet model; `CODESTYLE.md`/`WORKFLOW.md` are `whole: true` in the spec, so their rewrites are the sharper drift (and the vehicle for finding #3). +11. **`develop` diverged from `main`** (ahead 1, behind 3). See Develop Drift. + +## Proposed Registry / Spec Updates + +- **Registry correction (release model).** `registry/repos.json` records `releaseTrigger: two-phase` for aiopurpleair, but the repo runs a path-gated **publish-on-merge self-publisher** (push to `main`/`develop` on shipped paths + dispatch, no schedule, no `PUBLISH_ON_MERGE` variable, one branch per run). Update the entry to reflect the continuous/publish-on-merge model, or (if two-phase is the intended target state) file convergence to add the schedule+matrix+opt-in gate. This is the primary finding. +- **Mypy question - resolved by hub #245.** aiopurpleair runs mypy as a second type gate alongside pyright; the strong-typing policy now permits mypy as an additional checker (required for HA platinum, useful for the `pydantic.mypy` plugin), so this is no longer drift. The remaining divergence is that this repo's `CODESTYLE.md`/`WORKFLOW.md` are project rewrites rather than the fleet whole-file carries (finding #10). +- **Spec signal (PyPI version source).** WORKFLOW.md D3.4 mandates `AssemblyFileVersion (M.N.P.B)` as the PyPI version base; this repo deliberately derives it from `SemVer2` (3-part `Major.Minor.Height`) + `.dev0`, arguing the 4-part revision leaks a meaningless component (`build-release-task.yml:92-94`). The 3-part form still satisfies the `--pre`-selectable + sorts-above-release intent. If this reasoning generalizes, the spec should bless the `SemVer2`-base form (or explain why the 4-part is required). +- **Spec signal (EOL root cause).** A second repo shows mixed-ending workflow files from LF-written `uses:` action-pin lines - here **despite** a correct global `[*] end_of_line = crlf` default, isolating `.gitattributes: * -text` (no renormalization) as the true root cause. Strengthens the case for a machine check that scans each tracked file's committed endings per-line against `.editorconfig` (a whole-file check misses this), and possibly for a narrow `.gitattributes` `text=auto eol=crlf` pin on `.github/workflows/**`. +- **Recurring cross-fleet drifts** present here too (off-baseline `repo-config/ruleset-*.json` filenames, committed rulesets omitting `bypass_actors`/`required_reviewers`, no `changes` paths-filter, non-fail-fast dispatch guard). Notably **absent** (already fixed): the missing global EOL default, NBGV without `IGNORE_GITHUB_REF`, and the one-directional embedded release gate - aiopurpleair carries the corrected forms of all three. diff --git a/reports/blog/audit.md b/reports/blog/audit.md index b6775807..9ee046ee 100644 --- a/reports/blog/audit.md +++ b/reports/blog/audit.md @@ -1,155 +1,155 @@ -# Audit: Blog - -- **Audited branch:** main (`2b132e4`) -- **Types:** `hugo`, `source-only` (from registry) -- **Verdict:** operational -- **Date:** 2026-08-05 -- **Run stamp:** `audit run 2026-08-05T21:57:38Z | hub 01507a0` - -Second full run, replacing the 2026-08-03 report in whole rather than editing it, per the run-stamp discipline in [`AUDIT.md`][audit] section 8. The previous run graded the repo as `source-only` against a hub that had no static-site type, and it recorded that it was due a re-run once the deploy existed. That is what this is. The `hugo` type and the `self-hosted` composable target have since landed, the deploy has run against both environments, and every `hugo.*` check is judged here for the first time. - -The three deviations the first run recorded against the repo are closed. [ptr727/Blog#27][blog-27] (the remote release tree was never pruned), [ptr727/Blog#28][blog-28] (the vendored theme recorded no upstream ref), and [ptr727/Blog#29][blog-29] (the generator pin was duplicated across two workflows) each have a fix on `main` and are cited as evidence below. No defect is open. Everything remaining is the hub having advanced past what this repo carries, plus one finding that is the hub's own to fix rather than the repo's. - -## Develop Drift - -`develop` vs `main`: ahead 0, behind 6. `git diff origin/develop origin/main` is empty, so the two branches carry identical trees and the six commits are the promotion's PR commits plus its merge commit. That is the benign ancestry artifact a merge-commit promotion always leaves. No drift finding: `main` carries no content `develop` lacks. - -## Dimensions - -| Dimension | Letter | Intent | Verdict | Evidence (file:line) | -| --- | --- | --- | --- | --- | -| hugo | pass | pass | pass | All nine checks pass, enumerated below | -| branch-model | pass | pass | pass | Both branches protected, exactly one ruleset per name, and both match `repo-config/develop.json` and `repo-config/main.json` by normalized diff. The required-status-check context is the fleet canonical `Check pull request workflow status job` (`repo-config/main.json:41`, `repo-config/develop.json:44`) and matches the aggregator's own name (`.github/workflows/test-pull-request.yml:26`). General settings diff clean against `repo-config/settings.json`, and the two state-dependent settings hold: the repo is public with discussions on, and `default_branch` is `main` | -| repo-setup | pass | pass | pass | No forbidden secret, no stale secret. `requiredSecrets` is empty and correct: the GitHub release needs no credential, and the deploy's credentials are per-environment. See the caveat below, because a clean verdict here says less than it looks like it does | -| linter-parity | pass | pass | pass | One config per linter, each driving CI: markdownlint-cli2, cspell, actionlint, editorconfig-checker, then shellcheck and shfmt, config validation, the site build, and the URL contract (`.github/workflows/validate-task.yml:28-88`) | -| recurring-violations | drift | pass | drift | 17 prose findings across 7 of the repo's own files, none in carried or vendored content. Line endings are LF and bound by git rather than only by the editor (`.gitattributes`). Enumerated below | -| readme-structure | pass | pass | pass | Sections present and in spec order, with Getting Started legitimately omitted and Installation and Usage N/A for a repo that ships no installable artifact. Build and Distribution carries all three subsections (`README.md:12,17,22`). The intro is 76 characters, link-free, and a single sentence (`README.md:3`), the GitHub About description mirrors it exactly, and `HISTORY.md:1-3` matches the README title and intro | -| workflow (WORKFLOW.md 5A/5B) | pass | pass | pass | Enumerated below | -| agent-instruction-set | drift | pass | drift | Seven verbatim units behind the canonical, all of them stale copies rather than absences, so each states its rule in an older form. Enumerated below | - -`csharp`, `nuget`, `pypi`, `python`, `console`, `docker`: N/A. The repo builds a static site and ships no package or image. It carries Python helper scripts under `checks/`, which is not enough to make it a `python` repo, since there is no package, no `pyproject.toml`, and no ruff or pyright surface for the dimension to check. - -**What a clean `repo-setup` does not cover.** The deploy's credentials are GitHub Environment secrets and variables rather than repository secrets, and [`spec/secrets.json`][secrets] has no vocabulary for an environment scope. The registry therefore declares an empty `requiredSecrets`, which is correct rather than a gap, because listing the names there would make the audit demand them in the repository Actions store where they deliberately are not. The consequence is that this dimension passing is no evidence at all that either environment is configured. What proves that is a deploy run reaching its verification step, which has happened for both environments and is outside what the audit can see. - -## Hugo Dimension, Check by Check - -| Check | Verdict | Evidence | -| --- | --- | --- | -| `hugo.build.strict` | pass | `hugo --gc --minify --panicOnWarning` at `.github/workflows/validate-task.yml:83` and the identical command at `deploy/make-release.sh:94`, so the gate and the deploy build the same way rather than in two variants | -| `hugo.urls.parity` | pass | `checks/check-url-parity.py:16-30` asserts a floor on each of the three lists before comparing, and `checks/check-live-urls.sh:18-25` does the same against the running site. The floors sit under the current counts (328 render, 917 redirect, 778 legacy media), so a list may grow but cannot collapse into a vacuous pass | -| `hugo.output.uncommitted` | pass | `public/` and `resources/_gen/` are gitignored (`.gitignore:5-6`) and neither is tracked. The Markdown glob excludes the imported archive, the vendored theme, and the render (`.github/workflows/validate-task.yml:31-35`), and cspell is scoped to `README.md` and `HISTORY.md` | -| `hugo.generator.pinned` | pass | Version and SHA-256 are pinned together at `.github/actions/install-hugo/action.yml:26-27`, verified by `sha256sum --check --strict` before install (line 33), and the extended build is asserted from the binary rather than inferred from the file name (lines 36-38). The pin is declared once, in the composite action both callers use (`validate-task.yml:76`, `deploy-site-task.yml:79`), and is not exposed as an overridable input. This closes [ptr727/Blog#29][blog-29], which is the stronger fix: the check asks that something assert two copies agree, and removing the second copy makes agreement structural | -| `hugo.vendored.provenance` | pass | `themes/README.md:10-16` records the upstream repository, the exact commit, its upstream date, its `git describe` form, and the license, and lines 23-26 record the two local edits against it. The record sits outside the vendored directory deliberately, so replacing that directory on an update does not take the record with it. This closes [ptr727/Blog#28][blog-28] | -| `hugo.deploy.environment` | pass | The deploy job binds `environment: ${{ inputs.environment }}` (`deploy-site-task.yml:62`) and takes the host, user, base URL, and known-hosts entry from that environment's variables and secrets, so the workflow file names no host, path, or address. A separate `assert-environment` job re-asserts the name (lines 38-55), which is what the check asks for, because the environment binding resolves before any step runs and a `workflow_call` caller is not bound by the dispatch choice list a human sees | -| `hugo.deploy.atomic` | pass | The release installs under its own immutable id beside the retained ones, and `--delete` is omitted on the upload precisely because at an environment root it would remove rollback targets (`deploy-site-task.yml:123,132-136`). `--link-dest` points at `current`, which still resolves to the previous release at that moment. The pointer moves as a separate step (lines 140-150), and locally the same flip is a temporary link renamed over the old one (`deploy/make-release.sh:206-210`), which is a single rename rather than a replace in place | -| `hugo.deploy.verified` | pass | The terminal step observes the running host rather than the transport's exit status (`deploy-site-task.yml:155-163`). `checks/check-live-urls.sh` asserts which environment answered from a response header (lines 130-139) and that the rules answering are the release just installed (lines 154-182), polling to a bounded timeout because the config reload is asynchronous. Unreachability is reported distinctly from an HTTP status in three separate places (lines 101-105, 145-152, 163-167), and the preflight separates a bad credential from a vanished site (lines 109-126) | -| `hugo.deploy.retention` | pass | `OPERATIONS.md:170` declares the count and names the host timer that owns the prune, and records that the release `current` resolves to is retained unconditionally without consuming one of the ten. `OPERATIONS.md:172` records why nothing prunes on the deploy path, which is that the deploy key then needs no delete capability. This is the second of the two shapes D5.6 allows, and it is the correct one here: the credential is confined write-only and cannot observe the destination, so an in-pipeline assertion would mean widening it. Note that `deploy/make-release.sh:212-230` does prune, but in CI it runs against `${RUNNER_TEMP}/bundle`, so it is a local scratch prune and satisfies nothing on its own. The host timer is what the verdict rests on. This closes [ptr727/Blog#27][blog-27] | - -## Workflow Assertions (5A and 5B) - -- **D1, PR fast feedback.** The pull request gate runs the reusable validation and gates the merge on one always-run aggregator whose name is the ruleset-bound context (`test-pull-request.yml:15-19,25-29`). -- **D4, release and publish.** The publisher is dispatch-only, refuses a ref that is not `main` or `develop` (`publish-release.yml:34-40`), pins the dispatch-time commit so a push landing after dispatch cannot release unvalidated (line 46), versions with NBGV (line 53), and derives prerelease from the ref (line 64). It runs the same validation gate first (lines 15-19). Proven end to end: release `1.0.11` on 2026-08-01 from `main`, and prerelease `1.0.17-g4b2def3ee9` on 2026-08-04 from `develop`. -- **D4, deploy.** The deploy is a separate `workflow_dispatch` from the release, so a redeploy of an unchanged commit mints no tag (`deploy-site.yml:3-12`). Production is refused from any ref other than `main`, compared against the full ref rather than `ref_name`, because a tag and a branch sharing a short name are separate namespaces and the `ref_name` form would accept a tag named `main` pointing anywhere (`deploy-site.yml:25-37`). The gate runs first, before anything is installed or written. -- **D7, concurrency and permissions.** Both dispatch workflows queue rather than cancel, each for a stated reason: a cancelled deploy leaves a release uploaded and unflipped, and a cancelled publish leaves a half-created GitHub release (`deploy-site.yml:14-17`, `publish-release.yml:6-10`). Both assertion jobs declare `permissions: {}`, since neither reads the repository. -- **Dependabot.** `github-actions` is declared and dual-targets `main` and `develop`, which is the whole implied set: the site has no package manifest, and the theme is vendored rather than pulled by a manager. - -## Defects - -None. - -## Drift Findings - -### Carried Content Behind the Canonical - -Seven verbatim units are stale, and none is absent. Every one is the hub advancing after this repo's last re-vendor, which is the propagation this model expects rather than anything the repo did. They fall into two groups. - -**The Markdown capitalization settlement ([#566][pr-566]).** Three units differ from the canonical only in the case of the word Markdown in prose: `GOVERNANCE.md > Documentation Style Conventions`, `GOVERNANCE.md > Repository Details`, and `.markdownlint-cli2.jsonc` (in two comments). No rule changed. - -**Four rule additions this repo has not yet carried.** These are substantive, and the repo currently states each rule in a form that is missing the new part: - -1. `GOVERNANCE.md > Repository Boundaries and Write Safety` is missing the rule that a refused write is reported and never re-shaped, and that the maintainer's say-so does not lift a refusal by the harness ([#569][pr-569]). -2. `GOVERNANCE.md > Communicating with the User` is missing both the form-follows-surface qualifier on clickable links and the whole rule that work blocked on the user is raised as a direct interactive prompt whose options are the actions themselves ([#561][pr-561]). -3. `GOVERNANCE.md > Release Model` is missing the filesystem-deploy leaf bullet, which is the one that describes this repo's own deploy shape, including the retention rule the `hugo.deploy.retention` verdict above is judged against ([#558][issue-558], [#560][pr-560]). -4. `GOVERNANCE.md > Branching Model` is missing the issue-closing-keyword rule, which moved here out of Release Model, so this repo carries it in the old location and will lose it on the next Release Model re-vendor if the two are done separately ([#563][issue-563]). - -Item 4 is the one to sequence carefully. A re-vendor that takes the new Release Model without also taking the new Branching Model drops the rule entirely rather than leaving it stale. - -### Prose - -17 findings from `scripts/prose_lint.py` over the repo with `content/`, `public/`, `themes/`, and `resources/` excluded. All 17 are in files this repo authors. - -| Check | Count | Locations | -| --- | --- | --- | -| semicolon | 6 | `OPERATIONS.md:80,176,186`, `TODO.md:31,104`, `deploy/README.md:110` | -| spelling | 5 | `OPERATIONS.md:231`, `deploy/README.md:197,219,231`, `deploy/make-release.sh:51` | -| comment-wrap | 4 | `.github/workflows/deploy-site-task.yml:35`, `checks/check-live-urls.sh:77,99,149` | -| comment-case | 1 | `.github/workflows/deploy-site-task.yml:139` | -| charset-unknown | 1 | `layouts/rss.xml:51` | - -Each was read rather than counted. The six semicolons all join independent clauses, which is the form the rule bans. The five spellings are British forms with US equivalents. The four wraps are comment sentences continuing onto a second line. The one case finding opens a comment sentence on a lowercase command name. The `charset-unknown` finding is not a violation and is carried to Escalations below, unchanged from the previous run. - -## Non-Findings - -**`GOVERNANCE.md > Repository Onboarding and Conformance` is absent, and correctly so.** [`spec/section-model.md`][section-model] line 51 declares it hub-only and not carried, so its absence here is conformance rather than drift. Recorded because a section-count comparison against the hub surfaces it and it reads like a gap. - -**The `Project Conventions` section is gone.** The previous run raised it as an undeclared `AGENTS.md` section and as escalation 1. The repo has since moved that content into the topical docs that own it, and its `AGENTS.md` now carries exactly the three declared sections. The escalation is resolved and is not repeated below. - -## Escalations - -Three, raised rather than resolved, per [`AUDIT.md`][audit] section 9. The first is new and is a hub defect rather than a repo one. - -### 1. The Template-Reference Check Contradicts the Byte-Locked Fleet Bootstrap Section - -[`spec/audit.py`][audit-runner] lines 691-695 flag any carried `AGENTS.md`, `GOVERNANCE.md`, or `.github/copilot-instructions.md` that contains the hub's name anywhere in the file, on the reasoning that the coordination flow is machinery a consumer should not see. That reasoning is sound and the check catches real cases. - -It has no exemption for `AGENTS.md > Fleet Bootstrap`, which the hub declares `verbatim` in [`spec/files.json`][files] and whose first sentence names the hub repository by path. Naming it is the section's entire function: it is the byte-locked entry point that tells an agent where the canonical rules live when nothing else present says so. Blog's line 11 is byte-identical to the hub's own `AGENTS.md:11`, and it is the file's only occurrence of the name. - -So the finding on this repo is unclearable by construction. The only way to satisfy the check is to delete or alter a section the hub byte-locks, which the verbatim check would then flag instead. This is not specific to Blog. It fires on any repo that has carried the current canonical, and it will therefore spread across the fleet as the carry propagates rather than staying a single-repo curiosity. The fix belongs in the check: scan the file with the `Fleet Bootstrap` block excised, so a reference outside that section is still caught. The finding above is recorded as a hub defect and is not counted against this repo's verdict. - -### 2. A Downstream Repo Holding a Report at the Hub's Own Report Path - -Blog carries `reports/Blog/audit.md`, a self-audit dated 2026-08-05 against hub `3b802b9`. [`AUDIT.md`][audit] section 8 states that the hub authors the report and that a report written by the repo being audited is a claim rather than evidence, and it forbids the route it anticipated, which is a downstream repo opening a pull request against the hub. It says nothing about a downstream repo keeping such a report in its own tree, and `spec/files.json` neither declares nor forbids the path. - -Two things follow. The paths differ from the hub's only in the case of one directory, `reports/Blog/audit.md` there against `reports/blog/audit.md` here, so a reader with both trees open has two documents at what reads as the same location, reaching verdicts on the same repository, and only one of them is evidence under the model. The case difference makes that worse rather than better, because it is invisible on a case-insensitive filesystem and is the kind of distinction a reader treats as a typo rather than as a boundary. The downstream copy is also stale by exactly the mechanism section 8 describes: it declares `types: ["source-only"]`, which the registry superseded before the file was written, so its own header disagrees with the registry it cites. - -The question for the spec is whether a repo self-auditing in its own tree is sanctioned, and if it is, under what name. The content is genuinely useful, because the repo checks things the hub cannot see, its environment secrets among them. The collision is with the path and with the word audit, not with the practice. Raised rather than resolved, since the answer changes `spec/files.json` for every repo rather than just this one. - -### 3. The Character-Set Tiers Do Not Classify the COPYRIGHT SIGN - -Unchanged from the previous run and still open. `layouts/rss.xml:51` emits the sign as U+00A9 in generated feed output, where the alternative is an ASCII transliteration in a machine-read document. The tiers in `GOVERNANCE.md` "Character Set" have no entry for it, so the linter reports `charset-unknown` rather than pass or fail. Every repo generating a feed or a rendered document hits the same gap. - -## Proposed Registry / Spec Updates - -- **Registry: no change.** The entry already declares `types: ["hugo", "source-only"]`, both publish targets, `dispatch-only`, and `lineEndings: "lf"`. Its four `driftNotes` all describe current, live deviations, none asserts outstanding work, and none names a check id, so nothing is retired by this run. -- **Conformance matrix: the `hugo` row is updated with this run's date and findings** in the same change as this report. -- **`spec/audit.py`: exempt the `Fleet Bootstrap` block from the template-reference scan**, per escalation 1. This is a hub defect with a fleet-wide blast radius and is the one item here that should not wait. -- **`spec/files.json` or `AUDIT.md` section 8: decide the downstream self-report question**, per escalation 2. -- **`GOVERNANCE.md` "Character Set": classify U+00A9**, per escalation 3. - -## Convergence - -Per [`AUDIT.md`][audit] section 10, the drift above is applied to the target by pull request, one focused PR per drift class, and the maintainer merges. Two classes are open here: - -1. **Re-vendor the seven stale units**, taking Branching Model and Release Model together for the reason in item 4 above. -2. **Fix the 17 prose findings**, which are all in this repo's own authored files. - -Neither is started. The repo also carries one open item of its own, [ptr727/Blog#33][blog-33], which is a retest of the deploy transport's SSH options against the real host. That is the repo's to close and is recorded here only so a reader is not surprised by it. - -<!-- Repo --> -[audit]: ../../AUDIT.md -[audit-runner]: ../../spec/audit.py -[files]: ../../spec/files.json -[secrets]: ../../spec/secrets.json -[section-model]: ../../spec/section-model.md - -<!-- External --> -[blog-27]: https://github.com/ptr727/Blog/issues/27 -[blog-28]: https://github.com/ptr727/Blog/issues/28 -[blog-29]: https://github.com/ptr727/Blog/issues/29 -[blog-33]: https://github.com/ptr727/Blog/issues/33 -[issue-558]: https://github.com/ptr727/ProjectTemplate/issues/558 -[issue-563]: https://github.com/ptr727/ProjectTemplate/issues/563 -[pr-560]: https://github.com/ptr727/ProjectTemplate/pull/560 -[pr-561]: https://github.com/ptr727/ProjectTemplate/pull/561 -[pr-566]: https://github.com/ptr727/ProjectTemplate/pull/566 -[pr-569]: https://github.com/ptr727/ProjectTemplate/pull/569 +# Audit: Blog + +- **Audited branch:** main (`2b132e4`) +- **Types:** `hugo`, `source-only` (from registry) +- **Verdict:** operational +- **Date:** 2026-08-05 +- **Run stamp:** `audit run 2026-08-05T21:57:38Z | hub 01507a0` + +Second full run, replacing the 2026-08-03 report in whole rather than editing it, per the run-stamp discipline in [`AUDIT.md`][audit] section 8. The previous run graded the repo as `source-only` against a hub that had no static-site type, and it recorded that it was due a re-run once the deploy existed. That is what this is. The `hugo` type and the `self-hosted` composable target have since landed, the deploy has run against both environments, and every `hugo.*` check is judged here for the first time. + +The three deviations the first run recorded against the repo are closed. [ptr727/Blog#27][blog-27] (the remote release tree was never pruned), [ptr727/Blog#28][blog-28] (the vendored theme recorded no upstream ref), and [ptr727/Blog#29][blog-29] (the generator pin was duplicated across two workflows) each have a fix on `main` and are cited as evidence below. No defect is open. Everything remaining is the hub having advanced past what this repo carries, plus one finding that is the hub's own to fix rather than the repo's. + +## Develop Drift + +`develop` vs `main`: ahead 0, behind 6. `git diff origin/develop origin/main` is empty, so the two branches carry identical trees and the six commits are the promotion's PR commits plus its merge commit. That is the benign ancestry artifact a merge-commit promotion always leaves. No drift finding: `main` carries no content `develop` lacks. + +## Dimensions + +| Dimension | Letter | Intent | Verdict | Evidence (file:line) | +| --- | --- | --- | --- | --- | +| hugo | pass | pass | pass | All nine checks pass, enumerated below | +| branch-model | pass | pass | pass | Both branches protected, exactly one ruleset per name, and both match `repo-config/develop.json` and `repo-config/main.json` by normalized diff. The required-status-check context is the fleet canonical `Check pull request workflow status job` (`repo-config/main.json:41`, `repo-config/develop.json:44`) and matches the aggregator's own name (`.github/workflows/test-pull-request.yml:26`). General settings diff clean against `repo-config/settings.json`, and the two state-dependent settings hold: the repo is public with discussions on, and `default_branch` is `main` | +| repo-setup | pass | pass | pass | No forbidden secret, no stale secret. `requiredSecrets` is empty and correct: the GitHub release needs no credential, and the deploy's credentials are per-environment. See the caveat below, because a clean verdict here says less than it looks like it does | +| linter-parity | pass | pass | pass | One config per linter, each driving CI: markdownlint-cli2, cspell, actionlint, editorconfig-checker, then shellcheck and shfmt, config validation, the site build, and the URL contract (`.github/workflows/validate-task.yml:28-88`) | +| recurring-violations | drift | pass | drift | 17 prose findings across 7 of the repo's own files, none in carried or vendored content. Line endings are LF and bound by git rather than only by the editor (`.gitattributes`). Enumerated below | +| readme-structure | pass | pass | pass | Sections present and in spec order, with Getting Started legitimately omitted and Installation and Usage N/A for a repo that ships no installable artifact. Build and Distribution carries all three subsections (`README.md:12,17,22`). The intro is 76 characters, link-free, and a single sentence (`README.md:3`), the GitHub About description mirrors it exactly, and `HISTORY.md:1-3` matches the README title and intro | +| workflow (WORKFLOW.md 5A/5B) | pass | pass | pass | Enumerated below | +| agent-instruction-set | drift | pass | drift | Seven verbatim units behind the canonical, all of them stale copies rather than absences, so each states its rule in an older form. Enumerated below | + +`csharp`, `nuget`, `pypi`, `python`, `console`, `docker`: N/A. The repo builds a static site and ships no package or image. It carries Python helper scripts under `checks/`, which is not enough to make it a `python` repo, since there is no package, no `pyproject.toml`, and no ruff or pyright surface for the dimension to check. + +**What a clean `repo-setup` does not cover.** The deploy's credentials are GitHub Environment secrets and variables rather than repository secrets, and [`spec/secrets.json`][secrets] has no vocabulary for an environment scope. The registry therefore declares an empty `requiredSecrets`, which is correct rather than a gap, because listing the names there would make the audit demand them in the repository Actions store where they deliberately are not. The consequence is that this dimension passing is no evidence at all that either environment is configured. What proves that is a deploy run reaching its verification step, which has happened for both environments and is outside what the audit can see. + +## Hugo Dimension, Check by Check + +| Check | Verdict | Evidence | +| --- | --- | --- | +| `hugo.build.strict` | pass | `hugo --gc --minify --panicOnWarning` at `.github/workflows/validate-task.yml:83` and the identical command at `deploy/make-release.sh:94`, so the gate and the deploy build the same way rather than in two variants | +| `hugo.urls.parity` | pass | `checks/check-url-parity.py:16-30` asserts a floor on each of the three lists before comparing, and `checks/check-live-urls.sh:18-25` does the same against the running site. The floors sit under the current counts (328 render, 917 redirect, 778 legacy media), so a list may grow but cannot collapse into a vacuous pass | +| `hugo.output.uncommitted` | pass | `public/` and `resources/_gen/` are gitignored (`.gitignore:5-6`) and neither is tracked. The Markdown glob excludes the imported archive, the vendored theme, and the render (`.github/workflows/validate-task.yml:31-35`), and cspell is scoped to `README.md` and `HISTORY.md` | +| `hugo.generator.pinned` | pass | Version and SHA-256 are pinned together at `.github/actions/install-hugo/action.yml:26-27`, verified by `sha256sum --check --strict` before install (line 33), and the extended build is asserted from the binary rather than inferred from the file name (lines 36-38). The pin is declared once, in the composite action both callers use (`validate-task.yml:76`, `deploy-site-task.yml:79`), and is not exposed as an overridable input. This closes [ptr727/Blog#29][blog-29], which is the stronger fix: the check asks that something assert two copies agree, and removing the second copy makes agreement structural | +| `hugo.vendored.provenance` | pass | `themes/README.md:10-16` records the upstream repository, the exact commit, its upstream date, its `git describe` form, and the license, and lines 23-26 record the two local edits against it. The record sits outside the vendored directory deliberately, so replacing that directory on an update does not take the record with it. This closes [ptr727/Blog#28][blog-28] | +| `hugo.deploy.environment` | pass | The deploy job binds `environment: ${{ inputs.environment }}` (`deploy-site-task.yml:62`) and takes the host, user, base URL, and known-hosts entry from that environment's variables and secrets, so the workflow file names no host, path, or address. A separate `assert-environment` job re-asserts the name (lines 38-55), which is what the check asks for, because the environment binding resolves before any step runs and a `workflow_call` caller is not bound by the dispatch choice list a human sees | +| `hugo.deploy.atomic` | pass | The release installs under its own immutable id beside the retained ones, and `--delete` is omitted on the upload precisely because at an environment root it would remove rollback targets (`deploy-site-task.yml:123,132-136`). `--link-dest` points at `current`, which still resolves to the previous release at that moment. The pointer moves as a separate step (lines 140-150), and locally the same flip is a temporary link renamed over the old one (`deploy/make-release.sh:206-210`), which is a single rename rather than a replace in place | +| `hugo.deploy.verified` | pass | The terminal step observes the running host rather than the transport's exit status (`deploy-site-task.yml:155-163`). `checks/check-live-urls.sh` asserts which environment answered from a response header (lines 130-139) and that the rules answering are the release just installed (lines 154-182), polling to a bounded timeout because the config reload is asynchronous. Unreachability is reported distinctly from an HTTP status in three separate places (lines 101-105, 145-152, 163-167), and the preflight separates a bad credential from a vanished site (lines 109-126) | +| `hugo.deploy.retention` | pass | `OPERATIONS.md:170` declares the count and names the host timer that owns the prune, and records that the release `current` resolves to is retained unconditionally without consuming one of the ten. `OPERATIONS.md:172` records why nothing prunes on the deploy path, which is that the deploy key then needs no delete capability. This is the second of the two shapes D5.6 allows, and it is the correct one here: the credential is confined write-only and cannot observe the destination, so an in-pipeline assertion would mean widening it. Note that `deploy/make-release.sh:212-230` does prune, but in CI it runs against `${RUNNER_TEMP}/bundle`, so it is a local scratch prune and satisfies nothing on its own. The host timer is what the verdict rests on. This closes [ptr727/Blog#27][blog-27] | + +## Workflow Assertions (5A and 5B) + +- **D1, PR fast feedback.** The pull request gate runs the reusable validation and gates the merge on one always-run aggregator whose name is the ruleset-bound context (`test-pull-request.yml:15-19,25-29`). +- **D4, release and publish.** The publisher is dispatch-only, refuses a ref that is not `main` or `develop` (`publish-release.yml:34-40`), pins the dispatch-time commit so a push landing after dispatch cannot release unvalidated (line 46), versions with NBGV (line 53), and derives prerelease from the ref (line 64). It runs the same validation gate first (lines 15-19). Proven end to end: release `1.0.11` on 2026-08-01 from `main`, and prerelease `1.0.17-g4b2def3ee9` on 2026-08-04 from `develop`. +- **D4, deploy.** The deploy is a separate `workflow_dispatch` from the release, so a redeploy of an unchanged commit mints no tag (`deploy-site.yml:3-12`). Production is refused from any ref other than `main`, compared against the full ref rather than `ref_name`, because a tag and a branch sharing a short name are separate namespaces and the `ref_name` form would accept a tag named `main` pointing anywhere (`deploy-site.yml:25-37`). The gate runs first, before anything is installed or written. +- **D7, concurrency and permissions.** Both dispatch workflows queue rather than cancel, each for a stated reason: a cancelled deploy leaves a release uploaded and unflipped, and a cancelled publish leaves a half-created GitHub release (`deploy-site.yml:14-17`, `publish-release.yml:6-10`). Both assertion jobs declare `permissions: {}`, since neither reads the repository. +- **Dependabot.** `github-actions` is declared and dual-targets `main` and `develop`, which is the whole implied set: the site has no package manifest, and the theme is vendored rather than pulled by a manager. + +## Defects + +None. + +## Drift Findings + +### Carried Content Behind the Canonical + +Seven verbatim units are stale, and none is absent. Every one is the hub advancing after this repo's last re-vendor, which is the propagation this model expects rather than anything the repo did. They fall into two groups. + +**The Markdown capitalization settlement ([#566][pr-566]).** Three units differ from the canonical only in the case of the word Markdown in prose: `GOVERNANCE.md > Documentation Style Conventions`, `GOVERNANCE.md > Repository Details`, and `.markdownlint-cli2.jsonc` (in two comments). No rule changed. + +**Four rule additions this repo has not yet carried.** These are substantive, and the repo currently states each rule in a form that is missing the new part: + +1. `GOVERNANCE.md > Repository Boundaries and Write Safety` is missing the rule that a refused write is reported and never re-shaped, and that the maintainer's say-so does not lift a refusal by the harness ([#569][pr-569]). +2. `GOVERNANCE.md > Communicating with the User` is missing both the form-follows-surface qualifier on clickable links and the whole rule that work blocked on the user is raised as a direct interactive prompt whose options are the actions themselves ([#561][pr-561]). +3. `GOVERNANCE.md > Release Model` is missing the filesystem-deploy leaf bullet, which is the one that describes this repo's own deploy shape, including the retention rule the `hugo.deploy.retention` verdict above is judged against ([#558][issue-558], [#560][pr-560]). +4. `GOVERNANCE.md > Branching Model` is missing the issue-closing-keyword rule, which moved here out of Release Model, so this repo carries it in the old location and will lose it on the next Release Model re-vendor if the two are done separately ([#563][issue-563]). + +Item 4 is the one to sequence carefully. A re-vendor that takes the new Release Model without also taking the new Branching Model drops the rule entirely rather than leaving it stale. + +### Prose + +17 findings from `scripts/prose_lint.py` over the repo with `content/`, `public/`, `themes/`, and `resources/` excluded. All 17 are in files this repo authors. + +| Check | Count | Locations | +| --- | --- | --- | +| semicolon | 6 | `OPERATIONS.md:80,176,186`, `TODO.md:31,104`, `deploy/README.md:110` | +| spelling | 5 | `OPERATIONS.md:231`, `deploy/README.md:197,219,231`, `deploy/make-release.sh:51` | +| comment-wrap | 4 | `.github/workflows/deploy-site-task.yml:35`, `checks/check-live-urls.sh:77,99,149` | +| comment-case | 1 | `.github/workflows/deploy-site-task.yml:139` | +| charset-unknown | 1 | `layouts/rss.xml:51` | + +Each was read rather than counted. The six semicolons all join independent clauses, which is the form the rule bans. The five spellings are British forms with US equivalents. The four wraps are comment sentences continuing onto a second line. The one case finding opens a comment sentence on a lowercase command name. The `charset-unknown` finding is not a violation and is carried to Escalations below, unchanged from the previous run. + +## Non-Findings + +**`GOVERNANCE.md > Repository Onboarding and Conformance` is absent, and correctly so.** [`spec/section-model.md`][section-model] line 51 declares it hub-only and not carried, so its absence here is conformance rather than drift. Recorded because a section-count comparison against the hub surfaces it and it reads like a gap. + +**The `Project Conventions` section is gone.** The previous run raised it as an undeclared `AGENTS.md` section and as escalation 1. The repo has since moved that content into the topical docs that own it, and its `AGENTS.md` now carries exactly the three declared sections. The escalation is resolved and is not repeated below. + +## Escalations + +Three, raised rather than resolved, per [`AUDIT.md`][audit] section 9. The first is new and is a hub defect rather than a repo one. + +### 1. The Template-Reference Check Contradicts the Byte-Locked Fleet Bootstrap Section + +[`spec/audit.py`][audit-runner] lines 691-695 flag any carried `AGENTS.md`, `GOVERNANCE.md`, or `.github/copilot-instructions.md` that contains the hub's name anywhere in the file, on the reasoning that the coordination flow is machinery a consumer should not see. That reasoning is sound and the check catches real cases. + +It has no exemption for `AGENTS.md > Fleet Bootstrap`, which the hub declares `verbatim` in [`spec/files.json`][files] and whose first sentence names the hub repository by path. Naming it is the section's entire function: it is the byte-locked entry point that tells an agent where the canonical rules live when nothing else present says so. Blog's line 11 is byte-identical to the hub's own `AGENTS.md:11`, and it is the file's only occurrence of the name. + +So the finding on this repo is unclearable by construction. The only way to satisfy the check is to delete or alter a section the hub byte-locks, which the verbatim check would then flag instead. This is not specific to Blog. It fires on any repo that has carried the current canonical, and it will therefore spread across the fleet as the carry propagates rather than staying a single-repo curiosity. The fix belongs in the check: scan the file with the `Fleet Bootstrap` block excised, so a reference outside that section is still caught. The finding above is recorded as a hub defect and is not counted against this repo's verdict. + +### 2. A Downstream Repo Holding a Report at the Hub's Own Report Path + +Blog carries `reports/Blog/audit.md`, a self-audit dated 2026-08-05 against hub `3b802b9`. [`AUDIT.md`][audit] section 8 states that the hub authors the report and that a report written by the repo being audited is a claim rather than evidence, and it forbids the route it anticipated, which is a downstream repo opening a pull request against the hub. It says nothing about a downstream repo keeping such a report in its own tree, and `spec/files.json` neither declares nor forbids the path. + +Two things follow. The paths differ from the hub's only in the case of one directory, `reports/Blog/audit.md` there against `reports/blog/audit.md` here, so a reader with both trees open has two documents at what reads as the same location, reaching verdicts on the same repository, and only one of them is evidence under the model. The case difference makes that worse rather than better, because it is invisible on a case-insensitive filesystem and is the kind of distinction a reader treats as a typo rather than as a boundary. The downstream copy is also stale by exactly the mechanism section 8 describes: it declares `types: ["source-only"]`, which the registry superseded before the file was written, so its own header disagrees with the registry it cites. + +The question for the spec is whether a repo self-auditing in its own tree is sanctioned, and if it is, under what name. The content is genuinely useful, because the repo checks things the hub cannot see, its environment secrets among them. The collision is with the path and with the word audit, not with the practice. Raised rather than resolved, since the answer changes `spec/files.json` for every repo rather than just this one. + +### 3. The Character-Set Tiers Do Not Classify the COPYRIGHT SIGN + +Unchanged from the previous run and still open. `layouts/rss.xml:51` emits the sign as U+00A9 in generated feed output, where the alternative is an ASCII transliteration in a machine-read document. The tiers in `GOVERNANCE.md` "Character Set" have no entry for it, so the linter reports `charset-unknown` rather than pass or fail. Every repo generating a feed or a rendered document hits the same gap. + +## Proposed Registry / Spec Updates + +- **Registry: no change.** The entry already declares `types: ["hugo", "source-only"]`, both publish targets, `dispatch-only`, and `lineEndings: "lf"`. Its four `driftNotes` all describe current, live deviations, none asserts outstanding work, and none names a check id, so nothing is retired by this run. +- **Conformance matrix: the `hugo` row is updated with this run's date and findings** in the same change as this report. +- **`spec/audit.py`: exempt the `Fleet Bootstrap` block from the template-reference scan**, per escalation 1. This is a hub defect with a fleet-wide blast radius and is the one item here that should not wait. +- **`spec/files.json` or `AUDIT.md` section 8: decide the downstream self-report question**, per escalation 2. +- **`GOVERNANCE.md` "Character Set": classify U+00A9**, per escalation 3. + +## Convergence + +Per [`AUDIT.md`][audit] section 10, the drift above is applied to the target by pull request, one focused PR per drift class, and the maintainer merges. Two classes are open here: + +1. **Re-vendor the seven stale units**, taking Branching Model and Release Model together for the reason in item 4 above. +2. **Fix the 17 prose findings**, which are all in this repo's own authored files. + +Neither is started. The repo also carries one open item of its own, [ptr727/Blog#33][blog-33], which is a retest of the deploy transport's SSH options against the real host. That is the repo's to close and is recorded here only so a reader is not surprised by it. + +<!-- Repo --> +[audit]: ../../AUDIT.md +[audit-runner]: ../../spec/audit.py +[files]: ../../spec/files.json +[secrets]: ../../spec/secrets.json +[section-model]: ../../spec/section-model.md + +<!-- External --> +[blog-27]: https://github.com/ptr727/Blog/issues/27 +[blog-28]: https://github.com/ptr727/Blog/issues/28 +[blog-29]: https://github.com/ptr727/Blog/issues/29 +[blog-33]: https://github.com/ptr727/Blog/issues/33 +[issue-558]: https://github.com/ptr727/ProjectTemplate/issues/558 +[issue-563]: https://github.com/ptr727/ProjectTemplate/issues/563 +[pr-560]: https://github.com/ptr727/ProjectTemplate/pull/560 +[pr-561]: https://github.com/ptr727/ProjectTemplate/pull/561 +[pr-566]: https://github.com/ptr727/ProjectTemplate/pull/566 +[pr-569]: https://github.com/ptr727/ProjectTemplate/pull/569 diff --git a/reports/conformance-matrix.md b/reports/conformance-matrix.md index 685ba75c..c449d356 100644 --- a/reports/conformance-matrix.md +++ b/reports/conformance-matrix.md @@ -1,43 +1,43 @@ -# Conformance Matrix - -Tracks, per supported repo **shape**, whether a **context-free agent stands it up cold** from the hub docs to an audit-passing state ([`STANDUP.md`][standup] "Self-Test"), and the date of the shape's most recent audit ([`AUDIT.md`][audit]). A shape is the project type(s) plus the workflow model, where `operational` is a `workflowModel` overlay rather than a `spec/project-types.json` type. The date is `YYYY-MM-DD`, and a `-` cell means not yet audited. A shape that cannot be stood up cold is a documentation defect rather than an agent failure, so iterate the docs until it can. - -`Cold-standup` values: `passing` (a fresh agent reaches operational), `gaps` (reaches partial, and the note records the first doc gap), `not-tested` (self-test not yet run for this shape). - -The primary shapes are stood up as whole repos. The **composable targets** (`nuget`, `pypi`, `docker`) layer a publish leaf onto a base repo and are exercised as part of a base shape's standup, not alone. - -## Primary Shapes - -| Shape | Reference repo | Cold-standup | Last audited | First gap / notes | -|---|---|---|---|---| -| `python` + `source-only` | Financial-Modeling | not-tested | - | Reference for the source-release (dispatch-only) profile. The downstream standup issue is open. | -| `hugo` + `source-only` + `release` | Blog | not-tested | 2026-08-05 | Hugo static site (#456, #558), stood up 2026-08-01 and cataloged 2026-08-03. Release and deploy are independent surfaces: a dispatch-only publisher cuts the tag, and a separate dispatch deploys to a `self-hosted` filesystem per environment. Reclassified off the interim `source-only`-alone declaration when the type landed. **The 2026-08-05 run is the first to judge the `hugo` checks**, since the 2026-08-03 one predated the type and graded the repo as `source-only` alone. All nine pass, the repo is operational, and the three deviations the first run recorded are closed (ptr727/Blog#27, ptr727/Blog#28, ptr727/Blog#29). Two drift classes stay open, both of them the hub having advanced: seven stale verbatim carries and 17 prose findings. This is the shape `hugo.deploy.retention` was written against: its deploy credential is confined write-only, so the deploy can neither prune nor read the destination back, and the prune is a host-side timer its runbook records as host-owned. The first draft of that check demanded an in-pipeline assertion and would have pushed a correct design to widen a deliberately narrow credential. The run also surfaced a hub defect rather than a repo one, carried as escalation 1 of the report: the template-reference check had no exemption for the byte-locked `Fleet Bootstrap` section, whose first sentence must name the hub, so it fired unclearably on every repo that had carried the current canonical. Fixed since, across all three surfaces that stated the rule: the scan in `spec/audit.py` excises a file's verbatim sections before looking for the name, and `GOVERNANCE.md` "Documentation Style Conventions" and `recurring.norepoxref` both carry the exception and its boundary. It cleared exactly two findings fleet-wide and kept the other eleven. | -| `csharp` + `console` | - | not-tested | - | | -| `csharp` + `docker` | - | not-tested | - | | -| `csharp` + `python` | PlexCleaner | not-tested | 2026-08-15 | First mixed-language shape (#339). Python is a stdlib-only `uvx` **scripts** profile subtree (`RegressionTests/`): no `uv.lock`, `pyproject.toml` lint/type config only, mypy checker, `python.uvlock.pinned` + `python.coverage.codecov` N/A, and `codecov.yml` stays required for the C# side. Both language rule-sets apply (CODESTYLE.md "Two profiles"). **The 2026-08-15 run is the first to judge the `python` checks**, since the 2026-07-04 one predated the type declaration and graded the repo as `csharp` + `console` + `docker` alone. The repo is operational: every mechanized check passes, and the two workflow divergences still standing (no `changes` paths-filter, a dispatch guard that skips rather than failing fast) are letter misses whose intent holds. Its two open drift items are prose and configuration rather than pipeline, and the pass also settled the two `investigate` gap dispositions the whole fleet was carrying. | -| `homeassistant` | - | not-tested | - | Standalone-config conventions (home-assistant/core), scored by the `ha.*` checks. | -| `eda` | - | not-tested | - | Data-zip release, pull consumer. | -| `upstream-wrapper` | - | not-tested | - | Tag from a committed state file, not SemVer2. | -| `codegen` | - | not-tested | - | Deterministic matrix over both branches. | -| `docs` | ProjectTemplate | not-tested | - | Governance hub, and CI is lint-only. | -| `operational` config | HomeAutomation-Config | not-tested | 2026-08-15 | `workflowModel: operational`, direct signed commits to `develop`, promotion-PR gate. Its `develop` ruleset carries no `pull_request` rule, so the branch discipline rests on the instruction rather than the gate. HomeAutomation-Config is the first operational repository with a committed report (`reports/homeautomation-config/audit.md`): `source-only` plus `operational`, clean on every mechanized check on `main` after its 2026-08-15 resync and promotion. Its `.editorconfig` and `.gitattributes` are an LF adaptation that the intent advisory keeps flagging by construction. | - -## Composable Targets - -| Target | Exercised via | Cold-standup | Notes | -|---|---|---|---| -| `nuget` | a `csharp` library base | not-tested | OIDC Trusted Publishing, with no stored key. | -| `pypi` | a `python` library base | not-tested | OIDC, with `environment: pypi`, `skip-existing: true`. | -| `docker` | any base with a Dockerfile | not-tested | Registry layer cache, and always re-push. | -| `self-hosted` | a `hugo` base | not-tested | rsync over SSH into a per-environment release directory, with an atomic pointer flip. Retention takes either D5.6 shape: the deploy asserts the count where its credential can observe the destination, and the host owns it where that credential is confined write-only, which is the case on the first member. Credentials are per-environment GitHub Environment secrets and variables rather than repository secrets, so `spec/secrets.json` declares the mechanism with an empty `requires` and the audit cannot see whether the environments are configured. | - -## Updating a Row - -1. Run the [`STANDUP.md`][standup] self-test for the shape (fresh agent, docs only). -2. Run [`AUDIT.md`][audit] against the result, then set `Cold-standup` and `Last audited`. -3. If the result is not `passing`, record the first doc gap and fix it in the hub (docs or manifests), then re-run. - -<!-- Repo --> - -[audit]: ../AUDIT.md -[standup]: ../STANDUP.md +# Conformance Matrix + +Tracks, per supported repo **shape**, whether a **context-free agent stands it up cold** from the hub docs to an audit-passing state ([`STANDUP.md`][standup] "Self-Test"), and the date of the shape's most recent audit ([`AUDIT.md`][audit]). A shape is the project type(s) plus the workflow model, where `operational` is a `workflowModel` overlay rather than a `spec/project-types.json` type. The date is `YYYY-MM-DD`, and a `-` cell means not yet audited. A shape that cannot be stood up cold is a documentation defect rather than an agent failure, so iterate the docs until it can. + +`Cold-standup` values: `passing` (a fresh agent reaches operational), `gaps` (reaches partial, and the note records the first doc gap), `not-tested` (self-test not yet run for this shape). + +The primary shapes are stood up as whole repos. The **composable targets** (`nuget`, `pypi`, `docker`) layer a publish leaf onto a base repo and are exercised as part of a base shape's standup, not alone. + +## Primary Shapes + +| Shape | Reference repo | Cold-standup | Last audited | First gap / notes | +|---|---|---|---|---| +| `python` + `source-only` | Financial-Modeling | not-tested | - | Reference for the source-release (dispatch-only) profile. The downstream standup issue is open. | +| `hugo` + `source-only` + `release` | Blog | not-tested | 2026-08-05 | Hugo static site (#456, #558), stood up 2026-08-01 and cataloged 2026-08-03. Release and deploy are independent surfaces: a dispatch-only publisher cuts the tag, and a separate dispatch deploys to a `self-hosted` filesystem per environment. Reclassified off the interim `source-only`-alone declaration when the type landed. **The 2026-08-05 run is the first to judge the `hugo` checks**, since the 2026-08-03 one predated the type and graded the repo as `source-only` alone. All nine pass, the repo is operational, and the three deviations the first run recorded are closed (ptr727/Blog#27, ptr727/Blog#28, ptr727/Blog#29). Two drift classes stay open, both of them the hub having advanced: seven stale verbatim carries and 17 prose findings. This is the shape `hugo.deploy.retention` was written against: its deploy credential is confined write-only, so the deploy can neither prune nor read the destination back, and the prune is a host-side timer its runbook records as host-owned. The first draft of that check demanded an in-pipeline assertion and would have pushed a correct design to widen a deliberately narrow credential. The run also surfaced a hub defect rather than a repo one, carried as escalation 1 of the report: the template-reference check had no exemption for the byte-locked `Fleet Bootstrap` section, whose first sentence must name the hub, so it fired unclearably on every repo that had carried the current canonical. Fixed since, across all three surfaces that stated the rule: the scan in `spec/audit.py` excises a file's verbatim sections before looking for the name, and `GOVERNANCE.md` "Documentation Style Conventions" and `recurring.norepoxref` both carry the exception and its boundary. It cleared exactly two findings fleet-wide and kept the other eleven. | +| `csharp` + `console` | - | not-tested | - | | +| `csharp` + `docker` | - | not-tested | - | | +| `csharp` + `python` | PlexCleaner | not-tested | 2026-08-15 | First mixed-language shape (#339). Python is a stdlib-only `uvx` **scripts** profile subtree (`RegressionTests/`): no `uv.lock`, `pyproject.toml` lint/type config only, mypy checker, `python.uvlock.pinned` + `python.coverage.codecov` N/A, and `codecov.yml` stays required for the C# side. Both language rule-sets apply (CODESTYLE.md "Two profiles"). **The 2026-08-15 run is the first to judge the `python` checks**, since the 2026-07-04 one predated the type declaration and graded the repo as `csharp` + `console` + `docker` alone. The repo is operational: every mechanized check passes, and the two workflow divergences still standing (no `changes` paths-filter, a dispatch guard that skips rather than failing fast) are letter misses whose intent holds. Its two open drift items are prose and configuration rather than pipeline, and the pass also settled the two `investigate` gap dispositions the whole fleet was carrying. | +| `homeassistant` | - | not-tested | - | Standalone-config conventions (home-assistant/core), scored by the `ha.*` checks. | +| `eda` | - | not-tested | - | Data-zip release, pull consumer. | +| `upstream-wrapper` | - | not-tested | - | Tag from a committed state file, not SemVer2. | +| `codegen` | - | not-tested | - | Deterministic matrix over both branches. | +| `docs` | ProjectTemplate | not-tested | - | Governance hub, and CI is lint-only. | +| `operational` config | HomeAutomation-Config | not-tested | 2026-08-15 | `workflowModel: operational`, direct signed commits to `develop`, promotion-PR gate. Its `develop` ruleset carries no `pull_request` rule, so the branch discipline rests on the instruction rather than the gate. HomeAutomation-Config is the first operational repository with a committed report (`reports/homeautomation-config/audit.md`): `source-only` plus `operational`, clean on every mechanized check on `main` after its 2026-08-15 resync and promotion. Its `.editorconfig` and `.gitattributes` are an LF adaptation that the intent advisory keeps flagging by construction. | + +## Composable Targets + +| Target | Exercised via | Cold-standup | Notes | +|---|---|---|---| +| `nuget` | a `csharp` library base | not-tested | OIDC Trusted Publishing, with no stored key. | +| `pypi` | a `python` library base | not-tested | OIDC, with `environment: pypi`, `skip-existing: true`. | +| `docker` | any base with a Dockerfile | not-tested | Registry layer cache, and always re-push. | +| `self-hosted` | a `hugo` base | not-tested | rsync over SSH into a per-environment release directory, with an atomic pointer flip. Retention takes either D5.6 shape: the deploy asserts the count where its credential can observe the destination, and the host owns it where that credential is confined write-only, which is the case on the first member. Credentials are per-environment GitHub Environment secrets and variables rather than repository secrets, so `spec/secrets.json` declares the mechanism with an empty `requires` and the audit cannot see whether the environments are configured. | + +## Updating a Row + +1. Run the [`STANDUP.md`][standup] self-test for the shape (fresh agent, docs only). +2. Run [`AUDIT.md`][audit] against the result, then set `Cold-standup` and `Last audited`. +3. If the result is not `passing`, record the first doc gap and fix it in the hub (docs or manifests), then re-run. + +<!-- Repo --> + +[audit]: ../AUDIT.md +[standup]: ../STANDUP.md diff --git a/reports/divergences.md b/reports/divergences.md index 291e9cab..8fe8f05e 100644 --- a/reports/divergences.md +++ b/reports/divergences.md @@ -1,68 +1,68 @@ -# Fleet divergence report - -Generated by `python3 spec/fidelity_honesty.py --report` - do not hand-edit. Curate dispositions in [`spec/divergences.json`][ledger] and regenerate. Each row reflects a repo's ground-truth branch at generation time. Git dates this file. - -## Burn-down - -### re-vendor - -- **.markdownlint-cli2.jsonc** - AudioCleaner, PhotoCleaner, aiopurpleair - Verbatim config held as a hand-modified copy rather than a past hub revision. Restore the current canonical. - -### investigate - -- **pyproject.toml** (manifest gap, carried by Financial-Modeling, aiopurpleair, homeassistant-purpleair) - The hub gained a config-only Scripts-profile pyproject.toml in #388. Decide whether to track it (intent, appliesTo python) after confirming the python repos carry an equivalent. - -### retire - -- **repo-config/configure.sh** (manifest gap, carried by ESPHome-NonRoot, Financial-Modeling, HomeAssistant-Config, LanguageTags, NxWitness, PhotoCleaner, Utilities, VSCode-Server-DotNetCore, Vantage-Config, aiopurpleair, homeassistant-purpleair) - The script is hub-hosted rather than carried, per GOVERNANCE.md "Hub-Hosted Tooling", so it is no longer a manifest entry and a downstream copy is retired rather than re-vendored. Every copy the fleet holds is the hub's content with nothing per-repo in it, and the copies predate the payload-driven check mode, which is the drift this removes rather than converges. Delete the copy as each repo is next visited and run the hub's script against the repo instead. The carriers are not enumerated in this ledger entry, because the fleet-wide gap pass names them from live state in the generated report and a hand-kept list understated them: it read six while fifteen repos carry one. The ruleset payloads and settings.json stay carried, since those are what the repo is audited against and what its own AUDIT.md diffs the live configuration against. - -### accepted - -- **.editorconfig-checker.json** - HolidayLights _(recorded HomeAutomation-Config now resolved)_ - Both carry a legitimate repo-specific Exclude list (HomeAutomation-Config excludes a Vantage/ subtree, HolidayLights excludes .fseq sequence files). The uniform Disable block is carried intent-equivalent. Exclude is inherently repo-local, which is why the unit is intent, not verbatim. -- **LICENSE** (manifest gap, carried by AudioCleaner, Blog, DevKitCIoT, ESPHome-Config, ESPHome-NonRoot, EspDinIoT, Financial-Modeling, HomeAssistant-Config, HomeAutomation-Config, KiCadLibrary, LanguageTags, MediaTools, NxWitness, PhotoCleaner, PlexCleaner, Utilities, VSCode-Server-DotNetCore, Vantage-Config, aiopurpleair, homeassistant-purpleair) - Each repo owns its license file. The hub does not standardize license text, so it is intentionally outside the manifest. -- **TODO.md** (manifest gap, carried by Blog, HomeAutomation-Config) - A repo's own running backlog, which STANDUP.md section 2 names as one of the four declared destinations for repo-specific content. It is advisory and required by no selector, so the manifest deliberately does not declare it and a repo's copy is its own content rather than a carry. -- **.husky/pre-commit** (manifest gap, carried by AudioCleaner, LanguageTags, MediaTools, NxWitness, PhotoCleaner, PlexCleaner, Utilities) - A path collision rather than a carry. Each repo's hook runs that repo's own language formatters, and the hub's runs the doc gates, so the seven downstream copies share the path and none of the content: LanguageTags and Utilities run language formatting and style with no Docker, PlexCleaner runs CSharpier and dotnet format, and the hub runs prose_lint.py and repo_gate.py. Verified by reading the downstream copies on 2026-08-10. -- **scripts/README.md** (manifest gap, carried by KiCadLibrary) - A path collision rather than a carry. KiCadLibrary's copy documents its own KiCad tooling (common.py, verify_library.py, build_library.py) beside the scripts it describes, and shares nothing with the hub's fleet-gate documentation. Verified by reading it on 2026-08-10. scripts/ is a generic path, so a repo with its own tooling directory matches this check without carrying anything of the hub's. - -## Untriaged - add a disposition to `spec/divergences.json` - -- **AGENTS.md > Fleet Bootstrap** - **not carried** by AudioCleaner, DevKitCIoT, ESPHome-NonRoot, Financial-Modeling, HolidayLights, HomeAssistant-Config, KiCadLibrary, LanguageTags, MediaTools, NxWitness, PhotoCleaner, Utilities, VSCode-Server-DotNetCore, Vantage-Config, aiopurpleair, homeassistant-purpleair, so the section never arrived rather than being edited (verbatim canonical) -- **AGENTS.md > Context and Delegation Discipline** - **not carried** by AudioCleaner, DevKitCIoT, ESPHome-NonRoot, HolidayLights, HomeAssistant-Config, KiCadLibrary, LanguageTags, MediaTools, NxWitness, Utilities, VSCode-Server-DotNetCore, Vantage-Config, aiopurpleair, homeassistant-purpleair, so the section never arrived rather than being edited (verbatim canonical) -- **AGENTS.md > Where the Rules Live** - **not carried** by AudioCleaner, DevKitCIoT, ESPHome-NonRoot, HolidayLights, HomeAssistant-Config, KiCadLibrary, LanguageTags, MediaTools, NxWitness, Utilities, VSCode-Server-DotNetCore, Vantage-Config, aiopurpleair, homeassistant-purpleair, so the section never arrived rather than being edited (verbatim canonical) -- **GOVERNANCE.md > Representative Data in Agent-Authored Text** - **not carried** by Financial-Modeling, so the section never arrived rather than being edited (verbatim canonical) -- **GOVERNANCE.md > Hub-Hosted Tooling** - **not carried** by Financial-Modeling, PhotoCleaner, so the section never arrived rather than being edited (verbatim canonical) - -## Mechanical re-vendor (verbatim stale copies) - -A past hub revision, not the current canonical - the audit already flags these as DRIFT. Copy the current file down. No judgment needed. - -- **AGENTS.md > Fleet Bootstrap** (2): Blog, ESPHome-Config -- **AGENTS.md > Context and Delegation Discipline** (1): Financial-Modeling -- **AGENTS.md > Where the Rules Live** (4): Blog, ESPHome-Config, Financial-Modeling, PhotoCleaner -- **GOVERNANCE.md > Foundational Principles** (1): Financial-Modeling -- **GOVERNANCE.md > Durable Knowledge and Self-Improvement** (4): Blog, ESPHome-Config, Financial-Modeling, PhotoCleaner -- **GOVERNANCE.md > Repository Boundaries and Write Safety** (4): Blog, ESPHome-Config, Financial-Modeling, PhotoCleaner -- **GOVERNANCE.md > Git and Commit Rules** (4): Blog, ESPHome-Config, Financial-Modeling, PhotoCleaner -- **GOVERNANCE.md > Branching Model** (4): Blog, ESPHome-Config, Financial-Modeling, PhotoCleaner -- **GOVERNANCE.md > Release Model** (4): Blog, ESPHome-Config, Financial-Modeling, PhotoCleaner -- **GOVERNANCE.md > Operational Repositories** (4): Blog, ESPHome-Config, Financial-Modeling, PhotoCleaner -- **GOVERNANCE.md > Hub-Hosted Tooling** (2): Blog, ESPHome-Config -- **GOVERNANCE.md > Pull Request Title and Commit Message Conventions** (4): Blog, ESPHome-Config, Financial-Modeling, PhotoCleaner -- **GOVERNANCE.md > Documentation Style Conventions** (4): Blog, ESPHome-Config, Financial-Modeling, PhotoCleaner -- **GOVERNANCE.md > Verification Discipline** (4): Blog, ESPHome-Config, Financial-Modeling, PhotoCleaner -- **GOVERNANCE.md > PR Review Etiquette** (4): Blog, ESPHome-Config, Financial-Modeling, PhotoCleaner -- **GOVERNANCE.md > Communicating with the User** (4): Blog, ESPHome-Config, Financial-Modeling, PhotoCleaner -- **GOVERNANCE.md > Workflow YAML Conventions** (4): Blog, ESPHome-Config, Financial-Modeling, PhotoCleaner -- **GOVERNANCE.md > Supported Development Platforms** (1): Financial-Modeling -- **GOVERNANCE.md > Editor and Tasks** (1): Financial-Modeling -- **GOVERNANCE.md > Repository Details** (2): Financial-Modeling, PhotoCleaner -- **.markdownlint-cli2.jsonc** (14): DevKitCIoT, ESPHome-NonRoot, Financial-Modeling, HolidayLights, HomeAssistant-Config, KiCadLibrary, LanguageTags, MediaTools, NxWitness, PhotoCleaner, Utilities, VSCode-Server-DotNetCore, Vantage-Config, homeassistant-purpleair -- **repo-config/develop.json** (4): Financial-Modeling, MediaTools, PhotoCleaner, Utilities -- **repo-config/operational/develop.json** (2): HomeAssistant-Config, Vantage-Config -- **repo-config/main.json** (6): Financial-Modeling, HomeAssistant-Config, MediaTools, PhotoCleaner, Utilities, Vantage-Config - -## Promote candidates (intent uniform -> verbatim) - -_None - no intent unit is currently fleet-uniform with the canonical._ - -[ledger]: ../spec/divergences.json +# Fleet divergence report + +Generated by `python3 spec/fidelity_honesty.py --report` - do not hand-edit. Curate dispositions in [`spec/divergences.json`][ledger] and regenerate. Each row reflects a repo's ground-truth branch at generation time. Git dates this file. + +## Burn-down + +### re-vendor + +- **.markdownlint-cli2.jsonc** - AudioCleaner, PhotoCleaner, aiopurpleair - Verbatim config held as a hand-modified copy rather than a past hub revision. Restore the current canonical. + +### investigate + +- **pyproject.toml** (manifest gap, carried by Financial-Modeling, aiopurpleair, homeassistant-purpleair) - The hub gained a config-only Scripts-profile pyproject.toml in #388. Decide whether to track it (intent, appliesTo python) after confirming the python repos carry an equivalent. + +### retire + +- **repo-config/configure.sh** (manifest gap, carried by ESPHome-NonRoot, Financial-Modeling, HomeAssistant-Config, LanguageTags, NxWitness, PhotoCleaner, Utilities, VSCode-Server-DotNetCore, Vantage-Config, aiopurpleair, homeassistant-purpleair) - The script is hub-hosted rather than carried, per GOVERNANCE.md "Hub-Hosted Tooling", so it is no longer a manifest entry and a downstream copy is retired rather than re-vendored. Every copy the fleet holds is the hub's content with nothing per-repo in it, and the copies predate the payload-driven check mode, which is the drift this removes rather than converges. Delete the copy as each repo is next visited and run the hub's script against the repo instead. The carriers are not enumerated in this ledger entry, because the fleet-wide gap pass names them from live state in the generated report and a hand-kept list understated them: it read six while fifteen repos carry one. The ruleset payloads and settings.json stay carried, since those are what the repo is audited against and what its own AUDIT.md diffs the live configuration against. + +### accepted + +- **.editorconfig-checker.json** - HolidayLights _(recorded HomeAutomation-Config now resolved)_ - Both carry a legitimate repo-specific Exclude list (HomeAutomation-Config excludes a Vantage/ subtree, HolidayLights excludes .fseq sequence files). The uniform Disable block is carried intent-equivalent. Exclude is inherently repo-local, which is why the unit is intent, not verbatim. +- **LICENSE** (manifest gap, carried by AudioCleaner, Blog, DevKitCIoT, ESPHome-Config, ESPHome-NonRoot, EspDinIoT, Financial-Modeling, HomeAssistant-Config, HomeAutomation-Config, KiCadLibrary, LanguageTags, MediaTools, NxWitness, PhotoCleaner, PlexCleaner, Utilities, VSCode-Server-DotNetCore, Vantage-Config, aiopurpleair, homeassistant-purpleair) - Each repo owns its license file. The hub does not standardize license text, so it is intentionally outside the manifest. +- **TODO.md** (manifest gap, carried by Blog, HomeAutomation-Config) - A repo's own running backlog, which STANDUP.md section 2 names as one of the four declared destinations for repo-specific content. It is advisory and required by no selector, so the manifest deliberately does not declare it and a repo's copy is its own content rather than a carry. +- **.husky/pre-commit** (manifest gap, carried by AudioCleaner, LanguageTags, MediaTools, NxWitness, PhotoCleaner, PlexCleaner, Utilities) - A path collision rather than a carry. Each repo's hook runs that repo's own language formatters, and the hub's runs the doc gates, so the seven downstream copies share the path and none of the content: LanguageTags and Utilities run language formatting and style with no Docker, PlexCleaner runs CSharpier and dotnet format, and the hub runs prose_lint.py and repo_gate.py. Verified by reading the downstream copies on 2026-08-10. +- **scripts/README.md** (manifest gap, carried by KiCadLibrary) - A path collision rather than a carry. KiCadLibrary's copy documents its own KiCad tooling (common.py, verify_library.py, build_library.py) beside the scripts it describes, and shares nothing with the hub's fleet-gate documentation. Verified by reading it on 2026-08-10. scripts/ is a generic path, so a repo with its own tooling directory matches this check without carrying anything of the hub's. + +## Untriaged - add a disposition to `spec/divergences.json` + +- **AGENTS.md > Fleet Bootstrap** - **not carried** by AudioCleaner, DevKitCIoT, ESPHome-NonRoot, Financial-Modeling, HolidayLights, HomeAssistant-Config, KiCadLibrary, LanguageTags, MediaTools, NxWitness, PhotoCleaner, Utilities, VSCode-Server-DotNetCore, Vantage-Config, aiopurpleair, homeassistant-purpleair, so the section never arrived rather than being edited (verbatim canonical) +- **AGENTS.md > Context and Delegation Discipline** - **not carried** by AudioCleaner, DevKitCIoT, ESPHome-NonRoot, HolidayLights, HomeAssistant-Config, KiCadLibrary, LanguageTags, MediaTools, NxWitness, Utilities, VSCode-Server-DotNetCore, Vantage-Config, aiopurpleair, homeassistant-purpleair, so the section never arrived rather than being edited (verbatim canonical) +- **AGENTS.md > Where the Rules Live** - **not carried** by AudioCleaner, DevKitCIoT, ESPHome-NonRoot, HolidayLights, HomeAssistant-Config, KiCadLibrary, LanguageTags, MediaTools, NxWitness, Utilities, VSCode-Server-DotNetCore, Vantage-Config, aiopurpleair, homeassistant-purpleair, so the section never arrived rather than being edited (verbatim canonical) +- **GOVERNANCE.md > Representative Data in Agent-Authored Text** - **not carried** by Financial-Modeling, so the section never arrived rather than being edited (verbatim canonical) +- **GOVERNANCE.md > Hub-Hosted Tooling** - **not carried** by Financial-Modeling, PhotoCleaner, so the section never arrived rather than being edited (verbatim canonical) + +## Mechanical re-vendor (verbatim stale copies) + +A past hub revision, not the current canonical - the audit already flags these as DRIFT. Copy the current file down. No judgment needed. + +- **AGENTS.md > Fleet Bootstrap** (2): Blog, ESPHome-Config +- **AGENTS.md > Context and Delegation Discipline** (1): Financial-Modeling +- **AGENTS.md > Where the Rules Live** (4): Blog, ESPHome-Config, Financial-Modeling, PhotoCleaner +- **GOVERNANCE.md > Foundational Principles** (1): Financial-Modeling +- **GOVERNANCE.md > Durable Knowledge and Self-Improvement** (4): Blog, ESPHome-Config, Financial-Modeling, PhotoCleaner +- **GOVERNANCE.md > Repository Boundaries and Write Safety** (4): Blog, ESPHome-Config, Financial-Modeling, PhotoCleaner +- **GOVERNANCE.md > Git and Commit Rules** (4): Blog, ESPHome-Config, Financial-Modeling, PhotoCleaner +- **GOVERNANCE.md > Branching Model** (4): Blog, ESPHome-Config, Financial-Modeling, PhotoCleaner +- **GOVERNANCE.md > Release Model** (4): Blog, ESPHome-Config, Financial-Modeling, PhotoCleaner +- **GOVERNANCE.md > Operational Repositories** (4): Blog, ESPHome-Config, Financial-Modeling, PhotoCleaner +- **GOVERNANCE.md > Hub-Hosted Tooling** (2): Blog, ESPHome-Config +- **GOVERNANCE.md > Pull Request Title and Commit Message Conventions** (4): Blog, ESPHome-Config, Financial-Modeling, PhotoCleaner +- **GOVERNANCE.md > Documentation Style Conventions** (4): Blog, ESPHome-Config, Financial-Modeling, PhotoCleaner +- **GOVERNANCE.md > Verification Discipline** (4): Blog, ESPHome-Config, Financial-Modeling, PhotoCleaner +- **GOVERNANCE.md > PR Review Etiquette** (4): Blog, ESPHome-Config, Financial-Modeling, PhotoCleaner +- **GOVERNANCE.md > Communicating with the User** (4): Blog, ESPHome-Config, Financial-Modeling, PhotoCleaner +- **GOVERNANCE.md > Workflow YAML Conventions** (4): Blog, ESPHome-Config, Financial-Modeling, PhotoCleaner +- **GOVERNANCE.md > Supported Development Platforms** (1): Financial-Modeling +- **GOVERNANCE.md > Editor and Tasks** (1): Financial-Modeling +- **GOVERNANCE.md > Repository Details** (2): Financial-Modeling, PhotoCleaner +- **.markdownlint-cli2.jsonc** (14): DevKitCIoT, ESPHome-NonRoot, Financial-Modeling, HolidayLights, HomeAssistant-Config, KiCadLibrary, LanguageTags, MediaTools, NxWitness, PhotoCleaner, Utilities, VSCode-Server-DotNetCore, Vantage-Config, homeassistant-purpleair +- **repo-config/develop.json** (4): Financial-Modeling, MediaTools, PhotoCleaner, Utilities +- **repo-config/operational/develop.json** (2): HomeAssistant-Config, Vantage-Config +- **repo-config/main.json** (6): Financial-Modeling, HomeAssistant-Config, MediaTools, PhotoCleaner, Utilities, Vantage-Config + +## Promote candidates (intent uniform -> verbatim) + +_None - no intent unit is currently fleet-uniform with the canonical._ + +[ledger]: ../spec/divergences.json diff --git a/reports/esphome-nonroot/audit.md b/reports/esphome-nonroot/audit.md index 3e029bbe..17110504 100644 --- a/reports/esphome-nonroot/audit.md +++ b/reports/esphome-nonroot/audit.md @@ -1,96 +1,96 @@ -# Audit: ESPHome-NonRoot - -- **Audited branch:** main (`b44b1a6ca3741ad42140e060d66a88c2d6e75a0f`) -- **Types:** docker, upstream-wrapper (from registry) -- **Verdict:** operational -- **Date:** 2026-07-04 - -## Develop Drift - -`develop` vs `main`: ahead 1, behind 44 (`gh api repos/ptr727/ESPHome-NonRoot/compare/main...develop` -> `status: diverged, ahead_by: 1, behind_by: 44`). **Diverged - a drift finding.** `develop` carries 1 commit `main` lacks and is 44 behind. Under the forward-only model (no `main -> develop` back-merge) `main`-only changes (the audited CI/CD rework, Dependabot and upstream-pin bumps merged straight to `main`) have not reached `develop`. The publisher's push trigger is `main`-only (`publish-release.yml:19-21`), so develop's daily pin bump is sync-only anyway; still worth reconciling. - -## Dimensions - -| Dimension | Letter | Intent | Verdict | Evidence (file:line) | -| --- | --- | --- | --- | --- | -| csharp | - | - | N/A | no `*.csproj`/`*.slnx`; Docker-only repo. `.editorconfig:55-222` retains the shared `[*.cs]`+ReSharper block as inert governance | -| nuget | - | - | N/A | no `dotnet nuget push` | -| pypi | - | - | N/A | ESPHome/device-builder are resolved from PyPI as upstream pins, not published | -| python | - | - | N/A | no Python project in-repo | -| console | - | - | N/A | no `build-executable-task.yml` | -| docker | pass | pass | pass | registry layer cache `buildcache-<branch>` (never `type=gha`) `build-docker-task.yml:104-107` (`cache-from` both branches, `cache-to` only-branch + only-on-push); trimmed `Docker/README.md` published via `peter-evans/dockerhub-description`, main-only `:114-121`; image always re-pushes on `inputs.push` (`dockerhub && !smoke`), independent of the release-create skip `build-release-task.yml:63,125`. Static `DOCKER_HUB_*` secret (expected for docker, not OIDC) | -| upstream-wrapper | pass | pass | pass | the leaf **consumes** `upstream-version.json`: `jq -r .esphome`/`.device_builder` `build-docker-task.yml:55,60`, driving the pinned `:<esphome-version>` image tag `:99` and `ESPHOME_VERSION`/`DEVICE_BUILDER_VERSION` build-args `:110-111` (genuinely pinned, not tagged off NBGV - better than the template's tracker-without-consumer default); tracker `bump-branch-prefix: upstream-version` + `branches: ["main","develop"]` (`check-upstream-version-task.yml:26,33`) match the merge-bot's `upstream-version-{main,develop}` pairs `merge-bot-pull-request.yml:71-72` | -| branch-model | drift | pass | drift | both branches protected (live rulesets `enforcement: active`); live match committed `repo-config/ruleset-{develop,main}.json` **except** the committed files omit `bypass_actors` (live carries `RepositoryRole 5 always`) and `required_reviewers: []`. Required-check `context` `Check pull request workflow status job` matches both committed and the repo's aggregator `name:` `test-pull-request.yml:48` - new fleet canonical, not drift. Off-baseline filenames - see Drift | -| repo-setup | pass | pass | pass | actions + dependabot stores both hold `DOCKER_HUB_USERNAME`, `DOCKER_HUB_ACCESS_TOKEN` (docker-hub) and `CODEGEN_APP_CLIENT_ID`, `CODEGEN_APP_PRIVATE_KEY` (merge-bot `merge-bot-pull-request.yml:42-43` + upstream tracker `check-upstream-version-task.yml:55-56`); no forbidden `NUGET_API_KEY`/`CODEGEN_APP_ID`. Stale unused `DOCKER_HUB_PASSWORD` - see Drift | -| linter-parity | pass | pass | pass | one `.markdownlint-cli2.jsonc` + `cspell.json` drive editor + CLI + CI (`validate-task.yml:30-45`); CI runs markdownlint `:31-33`, cspell `:36-42`, actionlint `:44-45`; no C#/Python configs needed (Docker-only) | -| recurring-violations | drift | pass | drift | comments concise; docs ASCII-clean (em-dash/smart-quote grep -> none); `cspell.json:3` sets `"language": "en-US"`. **Letter miss (eol):** `.editorconfig` lacks the global `[*] end_of_line = crlf` default **and** 5 workflow YAML are committed LF against its own `[*.{yml,yaml}] crlf` rule - see Drift #1/#2 | -| readme-structure | drift | pass | drift | distribution bullets, status shields, Release Notes, Usage/Configuration/Compose, Use Case, License present and content-complete, but reshaped: no `## Table of Contents`, no `## Questions or Issues`, `## Status`/`## Release Notes` are top-level `##` siblings rather than `###` under `## Build and Distribution`, `## Use Case` `README.md:163` sits **after** `## Usage` `:32`, and the reference-link block `:210-255` has no `<!-- Shields -->`/`<!-- Repo -->`/`<!-- External -->` dividers. `Docker/README.md` is the intentionally-trimmed Hub overview, not measured against the project-README spec | -| workflow (WORKFLOW.md 5A/5B) | drift | pass | drift | all applicable D-guarantees hold by **outcome**; divergences are structural (see 5A/5B): single-branch-per-run publisher, no `changes` paths-filter, NBGV without `IGNORE_GITHUB_REF`, dispatch guard skips instead of failing fast, tag-only release without `expect_release_assets: false` | - -## WORKFLOW.md 5A Static Audit (applicable D-guarantees) - -- **D1.1 / D1.4 (paths-filter):** DRIFT - no `changes`/`dorny/paths-filter` job; `validate` and `smoke-build` run on **every** push (`test-pull-request.yml:11-42`, deliberate per header `:3-6`). Over-builds; the "changed target slips unbuilt" failure cannot occur with a single always-built target. -- **D1.2 (validation always runs):** PASS - `validate` (the lint gate, this Docker-only repo's replacement for `unit-test`) runs unconditionally `if: !github.event.deleted` (`test-pull-request.yml:25-28`); the aggregator and `smoke-build` both reuse/`needs:` it. -- **D1.3 (smoke uploads/pushes nothing):** PASS - the smoke caller passes `github: false`, `dockerhub: false`, `smoke: true` (`test-pull-request.yml:38-42`); the Docker push is gated `inputs.dockerhub && !inputs.smoke` (`build-release-task.yml:63`), github-release `inputs.github && !inputs.smoke` `:68`. No `upload-artifact` exists (Docker pushes directly). -- **D1.5 (one aggregator):** PASS - `check-workflow-status`, `needs: [validate, smoke-build]`, `always() && !github.event.deleted`, fails on any non-`success` (`test-pull-request.yml:47-63`). Name is the ruleset-bound context `:48`. -- **D2.1 / D2.2 (validate-at-entry):** DRIFT (letter) / PASS (intent) - the release gate is the `Verify public release version step` **inside** github-release (`build-release-task.yml:82-92`), not an upfront entry job; it checks **one direction only** (main-not-prerelease) and is `!smoke`-guarded. Safe because `version.json:4-6` makes `main` the sole public ref. -- **D2.3 (dispatch guard):** DRIFT - the publish job `if: github.ref_name == 'main' || github.ref_name == 'develop'` (`publish-release.yml:38`) **silently skips** a dispatch from any other ref rather than failing fast with `::error::`. -- **D3.1 (version from checked-out branch):** DRIFT (letter) / PASS (intent) - NBGV runs once in `get-version` on `inputs.ref` (`get-version-task.yml:47-56`); **no** `IGNORE_GITHUB_REF=true`. Correct because the publisher builds one branch per run with `github.ref` aligned; a future both-branch matrix would misclassify. -- **D3.2 (default = public, others = prerelease):** PASS - `version.json:4-6` `publicReleaseRefSpec ^refs/heads/main$`; gate literal `inputs.branch == 'main'` `build-release-task.yml:83`; `prerelease: inputs.branch != 'main'` `:131`; image tags `main => latest`, else `develop` `build-docker-task.yml:98`. -- **D3.3 (floor + git height):** PASS - `version.json:3` `version "1.8"`; NBGV appends the git height; retained though the repo has no compiled code (owns the release tag/`GitCommitId`). -- **D3.4 (registry version per classification):** PASS - every image carries the `:SemVer2` tag (`build-docker-task.yml:100`) plus the moving `latest`/`develop` and the pinned `:<esphome-version>` tag `:98-99`. -- **D3.5 (wrapper external version):** PASS - the build reads the committed `name -> version` state file `upstream-version.json` (`esphome 2026.6.4`, `device_builder 1.0.28`) for the immutable image tag and build-args (`build-docker-task.yml:55,60,99,110-111`), while NBGV still tags the release. This is the consumer wiring the template ships the tracker but not. -- **D4.1 (two-phase / both branches):** DRIFT - the publisher triggers on `schedule` (weekly, `main` only), `workflow_dispatch`, and a `main`-only `push` filtered to `upstream-version.json` (`publish-release.yml:14-21`); ordinary code merges never publish. But it builds **only** the trigger branch `:44-45`, not both via a matrix; `develop` publishes only via manual dispatch. Outcomes correct; structural divergence. -- **D4.2 (tag the built commit):** PASS - `target_commitish: needs.get-version.outputs.GitCommitId` `build-release-task.yml:130`; github-release checks out the same commit `:78`. -- **D4.3 (release contents):** DRIFT (letter) / PASS (intent) - tag + `generate_release_notes` + `LICENSE` + `README.md` `build-release-task.yml:128-135`; `prerelease = branch != main`. This Docker-only repo has **no** `expect_release_assets: false` input - the bespoke release task instead omits `fail_on_unmatched_files` and lets `pattern: release-asset-<branch>-*` match zero files (`:96-101,123`). Equivalent tag-only outcome; forks the template's mechanism. -- **D4.4 (no-op republish):** PASS - release-create gated `exists == false || workflow_dispatch` `build-release-task.yml:104-125`; Docker still re-pushes within the same run. -- **D5 (cleanup):** N/A - no `upload-artifact` / cross-job transfer artifact (Docker pushes to the registry directly; the release attaches repo files, not a produced asset). -- **D6.1 / D6.2 / D6.3 (seam):** PASS (mostly N/A) - the release download uses `pattern:`/`merge-multiple:` (no `artifact-ids:`) `build-release-task.yml:99-100`; branch-derived config/tags key off `inputs.branch` (`build-docker-task.yml:98-99`, `build-release-task.yml:83,131`), never `github.ref_name`; the single Docker target contributes no `release-asset-*` (registry push). -- **D7.1 / D7.2 (concurrency / permissions):** PASS - publisher group `${{ github.workflow }}` ref-independent + `cancel-in-progress: false` (`publish-release.yml:25-27`); the publish caller grants `contents: write` at the one entry point `:41-42`; the tracker and merge-bot jobs declare least-privilege `permissions:`; Docker uses the static secret (no OIDC). -- **D7.4 (optional-dependency chaining):** PASS - `build-docker` uses `!cancelled() && get-version == 'success' && (validate == 'success' || 'skipped')` `build-release-task.yml:53`. -- **D8.1 (merge-bot):** PASS - auto-merge on `opened`/`reopened`, method by base ref (`develop => --squash`, `main => --merge`) `merge-bot-pull-request.yml:48-56`; a separate `merge-upstream-version` job pins the bump-PR pairing `:66-73`; disable on maintainer `synchronize` `:108-115`; concurrency keyed on the PR number `:18`. -- **D8.3 (upstream-version tracker):** PASS - a daily scheduled resolver (`check-upstream-version.yml:9-12,35-41`) prints a `name -> version` JSON object (both PyPI components), writes CRLF-normalized `upstream-version.json` (`check-upstream-version-task.yml:88-90`), opens a rolling App-signed per-branch bump PR naming only moved keys `:94-133`, the merge-bot auto-merges it, and the build reads the pin on the next publish. -- **D9.1 (SHA-pinning):** PASS - all actions SHA-pinned with version comments; `dotnet/nbgv@master` is the sanctioned lagging-tag exception (`get-version-task.yml:52-56`). -- **D9.4 (docker cache):** PASS - registry `buildcache-<branch>`, `cache-to` writes only the built branch and only on push, `cache-from` reads both (`build-docker-task.yml:104-107`). -- **D9.5 (line endings):** DRIFT - see Drift #1 (5 workflow YAML committed LF against `.editorconfig`). -- **NuGet / PyPI / console 5A addenda:** N/A. - -## WORKFLOW.md 5B Trace (applicable scenarios) - -- **S1** (PR touches the Docker target): `validate` (lint) + `smoke-build` (amd64-only, no push) run; no push/upload; github-release skipped; aggregator success; version prerelease. PASS. -- **S2 / S3** (docs-only / workflow-only PR): `smoke-build` **runs** (no paths-filter) instead of skipping; aggregator success - safe but over-builds. DRIFT. -- **S4** (PR base = main): smoke prerelease; the main-only verify step is inside the `!smoke` github-release so it does not fire; promotion not blocked. PASS. -- **S5** (push not the pin file / non-main): the publisher push trigger is `main`-only and path-scoped to `upstream-version.json` (`publish-release.yml:19-21`), so no push publishes. PASS (stronger than opt-in). -- **S6** (push to develop): N/A - no develop push-publish path; develop's daily pin bump merges but publishes only via manual dispatch. -- **S7** (scheduled/dispatched publish): builds **only** the trigger branch. Schedule -> `main` -> stable + `latest` + pinned `:<esphome-version>`, Docker Hub overview refreshed; dispatch from `develop` -> prerelease + `develop` tag. DRIFT (single-branch); outcomes correct. -- **S8** (dispatch from a non-publishable ref): publish job **skipped** (template fails fast). DRIFT. -- **S9** (re-run, version unchanged): release-create skipped (tag exists, non-dispatch); Docker still re-pushes; no duplicate release. PASS. -- **S10** (branch/version disagree): the main-only verify step fails loud if `main` carries a prerelease suffix (`build-release-task.yml:88-91`). PASS. -- **S11** (wrapper bump): daily tracker detects a PyPI move -> commits `upstream-version.json` -> opens `upstream-version-{main,develop}` PRs -> merge-bot auto-merges each -> the `main` merge's path-scoped push publishes immediately with the bumped pin; the `develop` bump ships only on the next dispatch. PASS (with the develop-publish caveat). - -## Defects (most severe first) - -None. No applicable check fails both letter and intent. - -## Drift Findings - -1. **Five workflow YAML files are committed with LF, violating the repo's own `.editorconfig` CRLF rule.** `build-docker-task.yml`, `build-release-task.yml`, `publish-release.yml`, `test-pull-request.yml`, and `validate-task.yml` are `i/lf` (`git ls-files --eol`), while `.editorconfig:35-36` mandates `[*.{yml,yaml}] end_of_line = crlf` and the other four workflow YAML are CRLF. Nothing caught the split because `.gitattributes:6` uses `* -text` (no normalization) and `.editorconfig` carries no global `[*] end_of_line = crlf` default (`:17-22`). A **concrete** `recurring.eol` violation - stronger than the abstract missing-default the other three repos showed. No runtime impact (Actions parses either), but a genuine hygiene miss and diff-churn risk. -2. **`.editorconfig` lacks the canonical global line-ending default.** The `[*]` block (`.editorconfig:17-22`) sets charset/indent but **no** `end_of_line`; CRLF is re-declared per file-type (`:26,31,36,41,53,60`) with LF pins for `*.sh`/Dockerfiles (`:44-49`) - the older per-extension form the strengthened `recurring.eol` flags. Template canonical is `[*] end_of_line = crlf` + LF pins. **(Recurs with Utilities / PlexCleaner / LanguageTags - now four repos, and here it enabled finding #1.)** -3. **Publisher builds one branch per run, not a both-branch matrix.** `publish-release.yml:14-21,44-45` publishes only the trigger branch; the weekly schedule and pin-push rebuild `main` only, `develop` only on manual dispatch. WORKFLOW.md D4.1's model schedules both via a matrix. Outcomes correct; `develop` gets no scheduled refresh. **(Recurs with PlexCleaner.)** -4. **No `changes` paths-filter; every push smoke-builds.** `test-pull-request.yml:11-42` runs `validate` + `smoke-build` on every push. **(Recurs across the fleet.)** -5. **Committed rulesets omit fields the live rulesets carry.** `repo-config/ruleset-{develop,main}.json` omit `bypass_actors` (live carries `RepositoryRole 5 always`) and `required_reviewers: []`. Enforcement intact (context matches). **(Recurs with LanguageTags.)** -6. **repo-config filenames off-baseline.** Committed as `repo-config/ruleset-{develop,main}.json`; `spec/files.json` expects `repo-config/develop.json` / `main.json`. **(Recurs with Utilities / PlexCleaner / LanguageTags.)** -7. **NBGV without `IGNORE_GITHUB_REF`; release gate one-directional and embedded.** `get-version-task.yml:47-56` runs NBGV with no `IGNORE_GITHUB_REF=true`; `build-release-task.yml:82-92` verifies only that `main` is not prerelease, inside github-release. Correct only because one branch is built per run. **(Recurs with PlexCleaner / LanguageTags.)** -8. **Dispatch guard skips instead of failing fast.** `publish-release.yml:38` silently no-ops a dispatch from a non-`main`/`develop` ref; WORKFLOW.md D2.3 wants a fail-fast `::error::`. **(Recurs across the fleet.)** -9. **Docker-only tag-only release without `expect_release_assets: false`.** The bespoke `build-release-task.yml` omits `fail_on_unmatched_files` and lets the asset glob match zero files (`:96-101,123`), attaching `LICENSE`/`README.md` directly. Equivalent tag-only outcome; forks the template's `expect_release_assets` mechanism. -10. **README reshaped from the canonical structure.** No `## Table of Contents`, no `## Questions or Issues`; `## Status`/`## Release Notes` are top-level `##` rather than `###` under `## Build and Distribution`; `## Use Case` `README.md:163` sits after `## Usage` `:32`; the reference-link block `:210-255` has no `<!-- Shields -->`/`<!-- Repo -->`/`<!-- External -->` dividers. Content complete; shape non-canonical. -11. **Stale `DOCKER_HUB_PASSWORD` secret.** Present in both stores but referenced by no workflow (all Docker steps use `DOCKER_HUB_ACCESS_TOKEN`). Not forbidden; a stale secret to prune. **(Mirrors PlexCleaner's unused `CODECOV_TOKEN`.)** -12. **`develop` diverged from `main`** (ahead 1, behind 44). See Develop Drift. - -## Proposed Registry / Spec Updates - -- Registry `ESPHome-NonRoot` types `["docker","upstream-wrapper"]`, publish `docker` via `static-secret`, status `cataloged` are accurate; no change. Consider advancing `status` once the structural workflow drifts and the EOL hygiene are addressed. -- **Spec signal (strongest EOL case yet).** The missing global `[*] end_of_line = crlf` default now recurs across four repos, and here it produced **actual** non-compliant endings (five workflow YAML committed LF) because `.gitattributes: * -text` does not renormalize - the abstract miss became concrete. Strengthens the case for a machine check that asserts (a) a global `[*] end_of_line` default exists and (b) tracked files' committed endings match `.editorconfig`. -- **Spec gap - merge-bot / upstream-tracker App secrets.** As with PlexCleaner, `spec/secrets.json` models `CODEGEN_APP_CLIENT_ID`/`CODEGEN_APP_PRIVATE_KEY` only under `codegen-app`, but here they are required by the **merge-bot** and the **upstream-version tracker** on a repo with no codegen. Model a `merge-bot`/`app-signing` mechanism requiring the App secrets so repo-setup does not read them as orphaned. -- **Spec gap - wrapper tag-only release without `expect_release_assets`.** The tag-only release here is reached by omitting `fail_on_unmatched_files` in a bespoke task rather than the template's `expect_release_assets: false`. If wrapper repos consistently hand-roll the release task, consider whether the spec should bless the omit-glob equivalent or require the flag. -- **Recurring cross-fleet drifts** (off-baseline `repo-config/ruleset-*.json` filenames, committed rulesets omitting `bypass_actors`/`required_reviewers`, no `changes` paths-filter, skip-not-fail dispatch guard, single-branch publisher, NBGV without `IGNORE_GITHUB_REF`) appear here too. +# Audit: ESPHome-NonRoot + +- **Audited branch:** main (`b44b1a6ca3741ad42140e060d66a88c2d6e75a0f`) +- **Types:** docker, upstream-wrapper (from registry) +- **Verdict:** operational +- **Date:** 2026-07-04 + +## Develop Drift + +`develop` vs `main`: ahead 1, behind 44 (`gh api repos/ptr727/ESPHome-NonRoot/compare/main...develop` -> `status: diverged, ahead_by: 1, behind_by: 44`). **Diverged - a drift finding.** `develop` carries 1 commit `main` lacks and is 44 behind. Under the forward-only model (no `main -> develop` back-merge) `main`-only changes (the audited CI/CD rework, Dependabot and upstream-pin bumps merged straight to `main`) have not reached `develop`. The publisher's push trigger is `main`-only (`publish-release.yml:19-21`), so develop's daily pin bump is sync-only anyway; still worth reconciling. + +## Dimensions + +| Dimension | Letter | Intent | Verdict | Evidence (file:line) | +| --- | --- | --- | --- | --- | +| csharp | - | - | N/A | no `*.csproj`/`*.slnx`; Docker-only repo. `.editorconfig:55-222` retains the shared `[*.cs]`+ReSharper block as inert governance | +| nuget | - | - | N/A | no `dotnet nuget push` | +| pypi | - | - | N/A | ESPHome/device-builder are resolved from PyPI as upstream pins, not published | +| python | - | - | N/A | no Python project in-repo | +| console | - | - | N/A | no `build-executable-task.yml` | +| docker | pass | pass | pass | registry layer cache `buildcache-<branch>` (never `type=gha`) `build-docker-task.yml:104-107` (`cache-from` both branches, `cache-to` only-branch + only-on-push); trimmed `Docker/README.md` published via `peter-evans/dockerhub-description`, main-only `:114-121`; image always re-pushes on `inputs.push` (`dockerhub && !smoke`), independent of the release-create skip `build-release-task.yml:63,125`. Static `DOCKER_HUB_*` secret (expected for docker, not OIDC) | +| upstream-wrapper | pass | pass | pass | the leaf **consumes** `upstream-version.json`: `jq -r .esphome`/`.device_builder` `build-docker-task.yml:55,60`, driving the pinned `:<esphome-version>` image tag `:99` and `ESPHOME_VERSION`/`DEVICE_BUILDER_VERSION` build-args `:110-111` (genuinely pinned, not tagged off NBGV - better than the template's tracker-without-consumer default); tracker `bump-branch-prefix: upstream-version` + `branches: ["main","develop"]` (`check-upstream-version-task.yml:26,33`) match the merge-bot's `upstream-version-{main,develop}` pairs `merge-bot-pull-request.yml:71-72` | +| branch-model | drift | pass | drift | both branches protected (live rulesets `enforcement: active`); live match committed `repo-config/ruleset-{develop,main}.json` **except** the committed files omit `bypass_actors` (live carries `RepositoryRole 5 always`) and `required_reviewers: []`. Required-check `context` `Check pull request workflow status job` matches both committed and the repo's aggregator `name:` `test-pull-request.yml:48` - new fleet canonical, not drift. Off-baseline filenames - see Drift | +| repo-setup | pass | pass | pass | actions + dependabot stores both hold `DOCKER_HUB_USERNAME`, `DOCKER_HUB_ACCESS_TOKEN` (docker-hub) and `CODEGEN_APP_CLIENT_ID`, `CODEGEN_APP_PRIVATE_KEY` (merge-bot `merge-bot-pull-request.yml:42-43` + upstream tracker `check-upstream-version-task.yml:55-56`); no forbidden `NUGET_API_KEY`/`CODEGEN_APP_ID`. Stale unused `DOCKER_HUB_PASSWORD` - see Drift | +| linter-parity | pass | pass | pass | one `.markdownlint-cli2.jsonc` + `cspell.json` drive editor + CLI + CI (`validate-task.yml:30-45`); CI runs markdownlint `:31-33`, cspell `:36-42`, actionlint `:44-45`; no C#/Python configs needed (Docker-only) | +| recurring-violations | drift | pass | drift | comments concise; docs ASCII-clean (em-dash/smart-quote grep -> none); `cspell.json:3` sets `"language": "en-US"`. **Letter miss (eol):** `.editorconfig` lacks the global `[*] end_of_line = crlf` default **and** 5 workflow YAML are committed LF against its own `[*.{yml,yaml}] crlf` rule - see Drift #1/#2 | +| readme-structure | drift | pass | drift | distribution bullets, status shields, Release Notes, Usage/Configuration/Compose, Use Case, License present and content-complete, but reshaped: no `## Table of Contents`, no `## Questions or Issues`, `## Status`/`## Release Notes` are top-level `##` siblings rather than `###` under `## Build and Distribution`, `## Use Case` `README.md:163` sits **after** `## Usage` `:32`, and the reference-link block `:210-255` has no `<!-- Shields -->`/`<!-- Repo -->`/`<!-- External -->` dividers. `Docker/README.md` is the intentionally-trimmed Hub overview, not measured against the project-README spec | +| workflow (WORKFLOW.md 5A/5B) | drift | pass | drift | all applicable D-guarantees hold by **outcome**; divergences are structural (see 5A/5B): single-branch-per-run publisher, no `changes` paths-filter, NBGV without `IGNORE_GITHUB_REF`, dispatch guard skips instead of failing fast, tag-only release without `expect_release_assets: false` | + +## WORKFLOW.md 5A Static Audit (applicable D-guarantees) + +- **D1.1 / D1.4 (paths-filter):** DRIFT - no `changes`/`dorny/paths-filter` job; `validate` and `smoke-build` run on **every** push (`test-pull-request.yml:11-42`, deliberate per header `:3-6`). Over-builds; the "changed target slips unbuilt" failure cannot occur with a single always-built target. +- **D1.2 (validation always runs):** PASS - `validate` (the lint gate, this Docker-only repo's replacement for `unit-test`) runs unconditionally `if: !github.event.deleted` (`test-pull-request.yml:25-28`); the aggregator and `smoke-build` both reuse/`needs:` it. +- **D1.3 (smoke uploads/pushes nothing):** PASS - the smoke caller passes `github: false`, `dockerhub: false`, `smoke: true` (`test-pull-request.yml:38-42`); the Docker push is gated `inputs.dockerhub && !inputs.smoke` (`build-release-task.yml:63`), github-release `inputs.github && !inputs.smoke` `:68`. No `upload-artifact` exists (Docker pushes directly). +- **D1.5 (one aggregator):** PASS - `check-workflow-status`, `needs: [validate, smoke-build]`, `always() && !github.event.deleted`, fails on any non-`success` (`test-pull-request.yml:47-63`). Name is the ruleset-bound context `:48`. +- **D2.1 / D2.2 (validate-at-entry):** DRIFT (letter) / PASS (intent) - the release gate is the `Verify public release version step` **inside** github-release (`build-release-task.yml:82-92`), not an upfront entry job; it checks **one direction only** (main-not-prerelease) and is `!smoke`-guarded. Safe because `version.json:4-6` makes `main` the sole public ref. +- **D2.3 (dispatch guard):** DRIFT - the publish job `if: github.ref_name == 'main' || github.ref_name == 'develop'` (`publish-release.yml:38`) **silently skips** a dispatch from any other ref rather than failing fast with `::error::`. +- **D3.1 (version from checked-out branch):** DRIFT (letter) / PASS (intent) - NBGV runs once in `get-version` on `inputs.ref` (`get-version-task.yml:47-56`); **no** `IGNORE_GITHUB_REF=true`. Correct because the publisher builds one branch per run with `github.ref` aligned; a future both-branch matrix would misclassify. +- **D3.2 (default = public, others = prerelease):** PASS - `version.json:4-6` `publicReleaseRefSpec ^refs/heads/main$`; gate literal `inputs.branch == 'main'` `build-release-task.yml:83`; `prerelease: inputs.branch != 'main'` `:131`; image tags `main => latest`, else `develop` `build-docker-task.yml:98`. +- **D3.3 (floor + git height):** PASS - `version.json:3` `version "1.8"`; NBGV appends the git height; retained though the repo has no compiled code (owns the release tag/`GitCommitId`). +- **D3.4 (registry version per classification):** PASS - every image carries the `:SemVer2` tag (`build-docker-task.yml:100`) plus the moving `latest`/`develop` and the pinned `:<esphome-version>` tag `:98-99`. +- **D3.5 (wrapper external version):** PASS - the build reads the committed `name -> version` state file `upstream-version.json` (`esphome 2026.6.4`, `device_builder 1.0.28`) for the immutable image tag and build-args (`build-docker-task.yml:55,60,99,110-111`), while NBGV still tags the release. This is the consumer wiring the template ships the tracker but not. +- **D4.1 (two-phase / both branches):** DRIFT - the publisher triggers on `schedule` (weekly, `main` only), `workflow_dispatch`, and a `main`-only `push` filtered to `upstream-version.json` (`publish-release.yml:14-21`); ordinary code merges never publish. But it builds **only** the trigger branch `:44-45`, not both via a matrix; `develop` publishes only via manual dispatch. Outcomes correct; structural divergence. +- **D4.2 (tag the built commit):** PASS - `target_commitish: needs.get-version.outputs.GitCommitId` `build-release-task.yml:130`; github-release checks out the same commit `:78`. +- **D4.3 (release contents):** DRIFT (letter) / PASS (intent) - tag + `generate_release_notes` + `LICENSE` + `README.md` `build-release-task.yml:128-135`; `prerelease = branch != main`. This Docker-only repo has **no** `expect_release_assets: false` input - the bespoke release task instead omits `fail_on_unmatched_files` and lets `pattern: release-asset-<branch>-*` match zero files (`:96-101,123`). Equivalent tag-only outcome; forks the template's mechanism. +- **D4.4 (no-op republish):** PASS - release-create gated `exists == false || workflow_dispatch` `build-release-task.yml:104-125`; Docker still re-pushes within the same run. +- **D5 (cleanup):** N/A - no `upload-artifact` / cross-job transfer artifact (Docker pushes to the registry directly; the release attaches repo files, not a produced asset). +- **D6.1 / D6.2 / D6.3 (seam):** PASS (mostly N/A) - the release download uses `pattern:`/`merge-multiple:` (no `artifact-ids:`) `build-release-task.yml:99-100`; branch-derived config/tags key off `inputs.branch` (`build-docker-task.yml:98-99`, `build-release-task.yml:83,131`), never `github.ref_name`; the single Docker target contributes no `release-asset-*` (registry push). +- **D7.1 / D7.2 (concurrency / permissions):** PASS - publisher group `${{ github.workflow }}` ref-independent + `cancel-in-progress: false` (`publish-release.yml:25-27`); the publish caller grants `contents: write` at the one entry point `:41-42`; the tracker and merge-bot jobs declare least-privilege `permissions:`; Docker uses the static secret (no OIDC). +- **D7.4 (optional-dependency chaining):** PASS - `build-docker` uses `!cancelled() && get-version == 'success' && (validate == 'success' || 'skipped')` `build-release-task.yml:53`. +- **D8.1 (merge-bot):** PASS - auto-merge on `opened`/`reopened`, method by base ref (`develop => --squash`, `main => --merge`) `merge-bot-pull-request.yml:48-56`; a separate `merge-upstream-version` job pins the bump-PR pairing `:66-73`; disable on maintainer `synchronize` `:108-115`; concurrency keyed on the PR number `:18`. +- **D8.3 (upstream-version tracker):** PASS - a daily scheduled resolver (`check-upstream-version.yml:9-12,35-41`) prints a `name -> version` JSON object (both PyPI components), writes CRLF-normalized `upstream-version.json` (`check-upstream-version-task.yml:88-90`), opens a rolling App-signed per-branch bump PR naming only moved keys `:94-133`, the merge-bot auto-merges it, and the build reads the pin on the next publish. +- **D9.1 (SHA-pinning):** PASS - all actions SHA-pinned with version comments; `dotnet/nbgv@master` is the sanctioned lagging-tag exception (`get-version-task.yml:52-56`). +- **D9.4 (docker cache):** PASS - registry `buildcache-<branch>`, `cache-to` writes only the built branch and only on push, `cache-from` reads both (`build-docker-task.yml:104-107`). +- **D9.5 (line endings):** DRIFT - see Drift #1 (5 workflow YAML committed LF against `.editorconfig`). +- **NuGet / PyPI / console 5A addenda:** N/A. + +## WORKFLOW.md 5B Trace (applicable scenarios) + +- **S1** (PR touches the Docker target): `validate` (lint) + `smoke-build` (amd64-only, no push) run; no push/upload; github-release skipped; aggregator success; version prerelease. PASS. +- **S2 / S3** (docs-only / workflow-only PR): `smoke-build` **runs** (no paths-filter) instead of skipping; aggregator success - safe but over-builds. DRIFT. +- **S4** (PR base = main): smoke prerelease; the main-only verify step is inside the `!smoke` github-release so it does not fire; promotion not blocked. PASS. +- **S5** (push not the pin file / non-main): the publisher push trigger is `main`-only and path-scoped to `upstream-version.json` (`publish-release.yml:19-21`), so no push publishes. PASS (stronger than opt-in). +- **S6** (push to develop): N/A - no develop push-publish path; develop's daily pin bump merges but publishes only via manual dispatch. +- **S7** (scheduled/dispatched publish): builds **only** the trigger branch. Schedule -> `main` -> stable + `latest` + pinned `:<esphome-version>`, Docker Hub overview refreshed; dispatch from `develop` -> prerelease + `develop` tag. DRIFT (single-branch); outcomes correct. +- **S8** (dispatch from a non-publishable ref): publish job **skipped** (template fails fast). DRIFT. +- **S9** (re-run, version unchanged): release-create skipped (tag exists, non-dispatch); Docker still re-pushes; no duplicate release. PASS. +- **S10** (branch/version disagree): the main-only verify step fails loud if `main` carries a prerelease suffix (`build-release-task.yml:88-91`). PASS. +- **S11** (wrapper bump): daily tracker detects a PyPI move -> commits `upstream-version.json` -> opens `upstream-version-{main,develop}` PRs -> merge-bot auto-merges each -> the `main` merge's path-scoped push publishes immediately with the bumped pin; the `develop` bump ships only on the next dispatch. PASS (with the develop-publish caveat). + +## Defects (most severe first) + +None. No applicable check fails both letter and intent. + +## Drift Findings + +1. **Five workflow YAML files are committed with LF, violating the repo's own `.editorconfig` CRLF rule.** `build-docker-task.yml`, `build-release-task.yml`, `publish-release.yml`, `test-pull-request.yml`, and `validate-task.yml` are `i/lf` (`git ls-files --eol`), while `.editorconfig:35-36` mandates `[*.{yml,yaml}] end_of_line = crlf` and the other four workflow YAML are CRLF. Nothing caught the split because `.gitattributes:6` uses `* -text` (no normalization) and `.editorconfig` carries no global `[*] end_of_line = crlf` default (`:17-22`). A **concrete** `recurring.eol` violation - stronger than the abstract missing-default the other three repos showed. No runtime impact (Actions parses either), but a genuine hygiene miss and diff-churn risk. +2. **`.editorconfig` lacks the canonical global line-ending default.** The `[*]` block (`.editorconfig:17-22`) sets charset/indent but **no** `end_of_line`; CRLF is re-declared per file-type (`:26,31,36,41,53,60`) with LF pins for `*.sh`/Dockerfiles (`:44-49`) - the older per-extension form the strengthened `recurring.eol` flags. Template canonical is `[*] end_of_line = crlf` + LF pins. **(Recurs with Utilities / PlexCleaner / LanguageTags - now four repos, and here it enabled finding #1.)** +3. **Publisher builds one branch per run, not a both-branch matrix.** `publish-release.yml:14-21,44-45` publishes only the trigger branch; the weekly schedule and pin-push rebuild `main` only, `develop` only on manual dispatch. WORKFLOW.md D4.1's model schedules both via a matrix. Outcomes correct; `develop` gets no scheduled refresh. **(Recurs with PlexCleaner.)** +4. **No `changes` paths-filter; every push smoke-builds.** `test-pull-request.yml:11-42` runs `validate` + `smoke-build` on every push. **(Recurs across the fleet.)** +5. **Committed rulesets omit fields the live rulesets carry.** `repo-config/ruleset-{develop,main}.json` omit `bypass_actors` (live carries `RepositoryRole 5 always`) and `required_reviewers: []`. Enforcement intact (context matches). **(Recurs with LanguageTags.)** +6. **repo-config filenames off-baseline.** Committed as `repo-config/ruleset-{develop,main}.json`; `spec/files.json` expects `repo-config/develop.json` / `main.json`. **(Recurs with Utilities / PlexCleaner / LanguageTags.)** +7. **NBGV without `IGNORE_GITHUB_REF`; release gate one-directional and embedded.** `get-version-task.yml:47-56` runs NBGV with no `IGNORE_GITHUB_REF=true`; `build-release-task.yml:82-92` verifies only that `main` is not prerelease, inside github-release. Correct only because one branch is built per run. **(Recurs with PlexCleaner / LanguageTags.)** +8. **Dispatch guard skips instead of failing fast.** `publish-release.yml:38` silently no-ops a dispatch from a non-`main`/`develop` ref; WORKFLOW.md D2.3 wants a fail-fast `::error::`. **(Recurs across the fleet.)** +9. **Docker-only tag-only release without `expect_release_assets: false`.** The bespoke `build-release-task.yml` omits `fail_on_unmatched_files` and lets the asset glob match zero files (`:96-101,123`), attaching `LICENSE`/`README.md` directly. Equivalent tag-only outcome; forks the template's `expect_release_assets` mechanism. +10. **README reshaped from the canonical structure.** No `## Table of Contents`, no `## Questions or Issues`; `## Status`/`## Release Notes` are top-level `##` rather than `###` under `## Build and Distribution`; `## Use Case` `README.md:163` sits after `## Usage` `:32`; the reference-link block `:210-255` has no `<!-- Shields -->`/`<!-- Repo -->`/`<!-- External -->` dividers. Content complete; shape non-canonical. +11. **Stale `DOCKER_HUB_PASSWORD` secret.** Present in both stores but referenced by no workflow (all Docker steps use `DOCKER_HUB_ACCESS_TOKEN`). Not forbidden; a stale secret to prune. **(Mirrors PlexCleaner's unused `CODECOV_TOKEN`.)** +12. **`develop` diverged from `main`** (ahead 1, behind 44). See Develop Drift. + +## Proposed Registry / Spec Updates + +- Registry `ESPHome-NonRoot` types `["docker","upstream-wrapper"]`, publish `docker` via `static-secret`, status `cataloged` are accurate; no change. Consider advancing `status` once the structural workflow drifts and the EOL hygiene are addressed. +- **Spec signal (strongest EOL case yet).** The missing global `[*] end_of_line = crlf` default now recurs across four repos, and here it produced **actual** non-compliant endings (five workflow YAML committed LF) because `.gitattributes: * -text` does not renormalize - the abstract miss became concrete. Strengthens the case for a machine check that asserts (a) a global `[*] end_of_line` default exists and (b) tracked files' committed endings match `.editorconfig`. +- **Spec gap - merge-bot / upstream-tracker App secrets.** As with PlexCleaner, `spec/secrets.json` models `CODEGEN_APP_CLIENT_ID`/`CODEGEN_APP_PRIVATE_KEY` only under `codegen-app`, but here they are required by the **merge-bot** and the **upstream-version tracker** on a repo with no codegen. Model a `merge-bot`/`app-signing` mechanism requiring the App secrets so repo-setup does not read them as orphaned. +- **Spec gap - wrapper tag-only release without `expect_release_assets`.** The tag-only release here is reached by omitting `fail_on_unmatched_files` in a bespoke task rather than the template's `expect_release_assets: false`. If wrapper repos consistently hand-roll the release task, consider whether the spec should bless the omit-glob equivalent or require the flag. +- **Recurring cross-fleet drifts** (off-baseline `repo-config/ruleset-*.json` filenames, committed rulesets omitting `bypass_actors`/`required_reviewers`, no `changes` paths-filter, skip-not-fail dispatch guard, single-branch publisher, NBGV without `IGNORE_GITHUB_REF`) appear here too. diff --git a/reports/homeassistant-purpleair/audit.md b/reports/homeassistant-purpleair/audit.md index 314e182f..2a3b03c8 100644 --- a/reports/homeassistant-purpleair/audit.md +++ b/reports/homeassistant-purpleair/audit.md @@ -1,100 +1,100 @@ -# Audit: homeassistant-purpleair - -- **Audited branch:** main (`a56cac0be951e5665f48b8d23c8e9e382c51feb5`) -- **Types:** python, homeassistant (from registry) -- **Verdict:** operational -- **Date:** 2026-07-05 - -## Develop Drift - -`develop` vs `main`: ahead 0, behind 34 (`gh api repos/ptr727/homeassistant-purpleair/compare/main...develop` -> `status: behind, ahead_by: 0, behind_by: 34`). **Stale - a drift finding.** `develop` carries nothing `main` lacks and is 34 commits behind. Under the forward-only model the accumulated `main` work (releases, Dependabot, doc edits) has not been carried down to `develop`. No release impact: the publisher is dispatch-only (`publish-release.yml:8`), the daily HA-matrix bot opens its PRs against `develop` directly (`check-ha-version.yml:42-44,310`), and merges never publish - but the two branches should be reconciled forward-only. - -## Dimensions - -| Dimension | Letter | Intent | Verdict | Evidence (file:line) | -| --- | --- | --- | --- | --- | -| python | drift | pass | drift | ruff present (`.ruff.toml:7-171`), pyright present (`pyrightconfig.json:1-7`), mypy `--strict` added for HA platinum `strict-typing` (`test-release-task.yml:71-73`). **Letter miss:** ruff/pyright are NOT in `pyproject.toml`, which carries only `[tool.pytest.ini_options]` (`pyproject.toml:1`) and `[tool.pylint]` (`:32`). The whole toolchain (pip + `requirements*.txt`, `custom_components/` layout, no uv/hatchling/src) mirrors `home-assistant/core`, not the fleet Python profile - see Spec Updates | -| homeassistant | pass | pass | pass | HACS push distribution is a GitHub release only, no registry (`publish-release.yml:94` `github: true`, no PyPI/Docker/NuGet). `manifest.json` well-formed: `domain: purpleair` (`:2`), `version: "0.0.0"` placeholder stamped from NBGV at build (`build-release-task.yml:30-43`), `requirements: ["ptr727-aiopurpleair==1.0.7"]` (`:17-19`), `iot_class: cloud_polling` (`:10`), `codeowners: ["@ptr727"]` (`:4-6`), `quality_scale: platinum` (`:16`). `hacs.json` `zip_release: true` + `filename: purpleair.zip` (`:5-6`), `homeassistant: 2026.4.0` matches `ha-test-versions.json:4` minimum and the README shield. `quality_scale.yaml`, `strings.json`, `translations/en.json`, `icons.json`, `brand/*.png` all present; hassfest (`test-release-task.yml:220`) + HACS (`:228`) validated in CI. No formal spec checks for these - see Spec Updates | -| nuget | - | - | N/A | no `dotnet nuget push` / `*.csproj` | -| pypi | - | - | N/A | not published to PyPI; the integration ships via HACS (a GitHub-release zip HACS extracts into `<config>/custom_components/`). No `build-pypilibrary-task` / `gh-action-pypi-publish`; `requirements*.txt` are dev/CI pins, not a distributed package. `_version.py`/OIDC/`environment: pypi` clauses do not apply | -| console | - | - | N/A | no executable build task / `System.CommandLine` | -| docker | - | - | N/A | no `Dockerfile` / `build-docker-task.yml` | -| branch-model | drift | pass | drift | both branches protected, live rulesets `enforcement: active`; develop allows only `squash` + `required_linear_history`, main allows only `merge` (`ruleset-{develop,main}.json`), required check `context: "Check pull request workflow status job"` matches the aggregator `name:` (`test-pull-request.yml:34`). Normalized diff: committed rulesets omit `required_reviewers: []` (live carries it) and top-level `bypass_actors` (live: `RepositoryRole 5 always` on both). Off-baseline filenames; extra live "Tag Rules" ruleset unrepresented - see Drift | -| repo-setup | pass | pass | pass | actions + dependabot stores both hold `CODEGEN_APP_CLIENT_ID`/`CODEGEN_APP_PRIVATE_KEY` (baseline merge-bot App, `merge-bot-pull-request.yml:45-46`) and `CODECOV_TOKEN` (codecov mechanism, used at `test-release-task.yml:204-207`); no forbidden `NUGET_API_KEY`/`PYPI_API_TOKEN`/`CODEGEN_APP_ID`; no publish secrets (github-release needs none). `CODECOV_TOKEN` in the dependabot store is beyond the mechanism's `stores: ["actions"]` but harmless | -| linter-parity | pass | pass | pass | one config each drives editor+CLI+CI, and CI runs each: markdownlint via `.markdownlint-cli2.jsonc` (`test-release-task.yml:108-110`), cspell via `cspell.json` `language: en-US` (`:113-119`), ruff via `.ruff.toml` (`:49-53`), pyright via `pyrightconfig.json` (`:91-95`), actionlint (`:121-122`), shellcheck (`:125-126`), plus mypy `--strict` (`:71-73`) | -| recurring-violations | drift | pass | drift | comments concise; spelling `en-US` (`cspell.json:3`); **EOL clean** - global `[*] end_of_line = crlf` default present (`.editorconfig:13`), LF pins for `*.sh`/`scripts/*` (`:29-33`) mirrored in `.gitattributes:12-13`, byte scan of all tracked files shows 68 CRLF / 6 correctly-LF (`scripts/*`, `repo-config/configure.sh`) / **0 mixed**. **Letter miss (charset):** em-dash (U+2014) and arrow (U+2192) pervade Python source comments/docstrings (`config_flow.py:120,136,...`, `coordinator.py:140`, `sensor.py`, `tests/*`) where ASCII equivalents (` - `, `->`) apply; docs are ASCII-clean; scientific symbols (U+00B5, U+00B3, U+00B2, U+00A7, U+00B7, U+2212) are within the carve-out, and U+2264/U+2265 are named must-replace by the charset rule. See Drift | -| readme-structure | pass | pass | pass | strongly canonical: Title + one-line desc (`README.md:1-3`); `## Build and Distribution` parent (`:5`) with `### Build Status` (`:7`), `### Releases` (`:16`), `### Release Notes` (`:21`); `## Table of Contents` (`:39`, MAiO inline anchors); `## Installation` (`:92`), `## Configuration` (`:106`), `## Questions or Issues` (`:280`), `## Contributing` covering dev setup (`:289`), `## Credits` (`:303`), `## License` last (`:310`); reference-style links defined at bottom (`:317+`). Minor: `## Features` in place of `## Use Cases` (intent-equivalent); no standalone "Development Environment Setup" heading (Contributing points to `DEVCONTAINER.md`) | -| workflow (WORKFLOW.md 5A/5B) | drift | pass | drift | all applicable D-guarantees hold by outcome; divergences are structural (see 5A/5B): D5.5 blanket artifact delete, no `changes` paths-filter, NBGV without `IGNORE_GITHUB_REF`, `target_commitish: github.sha`, bespoke non-`release-asset-*` seam, no release-exists no-op gate. The dispatch-ref guard is a **correct fail-fast** (not the recurring skip drift) | - -## WORKFLOW.md 5A Static Audit (applicable D-guarantees) - -The pipeline is deliberately reimagined for HA/HACS: `push:['**']` self-tests every branch (`test-pull-request.yml:11-12`), `workflow_dispatch` from `main`/`develop` publishes (`publish-release.yml:8,74-94`), and the weekly `schedule` **retests `main` and stops** (`:13-14,77`). One dispatch = one branch = one ref, so there is **no branch matrix**; the template guards that defend a matrix publisher against building the wrong branch from the default ref are structurally unnecessary here. - -- **D1.1 / D1.4 (paths-filter):** DRIFT - no `changes`/`dorny/paths-filter` job anywhere (`grep` -> none). Every push runs the full `test-release-task` suite, including docs-only and workflow-only pushes. Over-builds; with a single always-built integration the "changed target slips unbuilt" failure cannot occur. (Recurs across the fleet.) -- **D1.2 (validation always runs):** PASS - `test-release-task.yml` runs ruff/mypy/pyright/docs-lint/pytest-matrix/hassfest/HACS unconditionally on every push; the aggregator `needs: [test-release]` (`test-pull-request.yml:36`) and fails on any non-`success` (`:43-45`). -- **D1.3 (smoke never publishes):** PASS (publish) / DRIFT (upload) - the PR path calls `test-release-task` with `build` defaulting true, so `build-release` runs with `github: false` (`test-release-task.yml:246`) - no release. But the build job **uploads** `purpleair-zip` on the PR path (`build-release-task.yml:101-107`); the template gates all uploads `!smoke`. Mitigated by `retention-days: 1` (`:107`) and no PR-path cleanup; the artifact is reaped within a day. -- **D1.5 (one aggregator):** PASS - `Check pull request workflow status job`, `needs: [test-release]`, `always() && !github.event.deleted`, fails on non-`success` (`test-pull-request.yml:33-45`). Name is the ruleset-bound context. -- **D2.2 (release branch vs classification):** N/A (no separate branch input) - NBGV derives `Prerelease` solely from the `-` in `SemVer2` (`get-version-task.yml:62-67`); `main` is the public ref (`version.json:4-6` `^refs/heads/main$`), so a `main` dispatch -> clean `X.Y.Z`, a `develop` dispatch -> `X.Y.Z-g<sha>`. There is no independent branch input for the ref to disagree with, so the template's two-direction `validate-release` gate has nothing to guard. -- **D2.3 (dispatch guard):** **PASS (fail-fast)** - the `gate` job asserts the dispatch ref is `main` or `develop` and **fails fast** with `::error::` + `exit 1` on any other ref (`publish-release.yml:33-39`). This is the template-correct form, NOT the recurring skip-not-fail drift. -- **D3.1 (version from checked-out branch):** DRIFT (letter) / PASS (intent) - NBGV runs once in `get-version` (`get-version-task.yml:40-44`) with **no** `IGNORE_GITHUB_REF=true`. Correct because each dispatch checks out its own ref (no matrix building `develop` from a `main`-ref run); a future both-branch matrix would misclassify. (Recurs cross-fleet.) -- **D3.2 (default = public):** PASS - `version.json:4-6` `publicReleaseRefSpec ^refs/heads/main$`; prerelease detection by `SemVer2` containing `-` (`get-version-task.yml:62-67`). -- **D3.3 (floor + git height):** PASS - `version.json:3` `version "1.0"`; NBGV appends git height. Retained though no compiled code (owns the release tag). `versionHeightOffset: -1` (`:10`). -- **D3.4 (registry version per classification):** N/A - no package registry; the release zip carries the NBGV `SemVer2`-stamped `manifest.json` (`build-release-task.yml:37-43`). -- **D3.5 (wrapper external version):** N/A - **not a build-version wrapper.** `check-ha-version.yml` tracks a CI **test matrix** (`.github/ha-test-versions.json`), not the build tag; the release tags off NBGV `SemVer2` (`build-release-task.yml:136`). See the tracker note under D8.3 and Spec Updates. -- **D4.1 (two-phase / merges never publish):** PASS (stronger) - the publisher has **no push trigger** at all (`publish-release.yml:8,13-14`); merges never publish and there is no `PUBLISH_ON_MERGE` variable (none configured). Publishing is a deliberate dispatch. Not a both-branch matrix (one branch per dispatch), but the HACS push-consumer model makes release restraint the point. -- **D4.2 (tag the built commit):** DRIFT (letter) / PASS (intent) - `target_commitish: github.sha` (`build-release-task.yml:137`), NOT NBGV `GitCommitId`. Safe because a dispatch runs from the actual branch tip and `get-version`/`build` both check out `github.sha`, so it IS the built commit; the template forbids `github.sha` specifically because its branch-matrix `develop` leg runs with `github.sha` = main's tip - a case that cannot arise here. -- **D4.3 (release contents):** PASS (intent) - tag + `generate_release_notes` + the `purpleair.zip` asset (`build-release-task.yml:134-141`), `prerelease` = `Prerelease == 'true'` (`:138`), `fail_on_unmatched_files: true` (`:140`). Uses a plain `name:`-based upload/download of `purpleair-zip` rather than the template's `release-asset-<branch>-*` pattern + `expect_release_assets`; equivalent tag+asset outcome, forks the seam (single-branch-per-run needs no branch suffix). -- **D4.4 (no-op republish):** DRIFT (letter) / PASS (intent) - **no** `release-exists`/`workflow_dispatch` gate on the create step; a re-dispatch with unchanged `SemVer2` reuses the same tag, so softprops updates the existing release in place (no duplicate). Publishing is a deliberate dispatch act, so an intentional refresh is acceptable; no NuGet/PyPI/Docker to dedupe. -- **D5.1-D5.4 (cleanup at consumption / retention):** DRIFT - the sole upload sets `retention-days: 1` (`build-release-task.yml:107`) (D5.4 PASS), but there is **no** surgical delete-at-consumption; cleanup is deferred to a terminal job. -- **D5.5 (never blanket-delete):** **DRIFT (letter miss of a MUST-NOT) / PASS (intent)** - `cleanup-artifacts` enumerates and deletes the **whole run artifact set** via `gh api .../artifacts --jq '.artifacts[].id'` (`publish-release.yml:114-122`) - the exact anti-pattern D5.5 forbids. The failure mode (destroying diagnostic/build-record artifacts) does not materialize because the pure-Python pipeline emits none and `purpleair-zip` already carries `retention-days: 1`; adding a Docker/other target that emits build-records would make this destructive. Ranked #1 in Drift. -- **D6.1 / D6.2 / D6.3 (seam):** DRIFT (letter) / PASS (intent) - artifact is `purpleair-zip`, downloaded by `name:` (`build-release-task.yml:117-121`), not a `release-asset-<branch>-*` `pattern:`/`merge-multiple:` handoff, and not branch-suffixed. Correct for a single-artifact, single-branch-per-run model; branch-derived classification keys off NBGV/`version.json`, never `github.ref_name`. -- **D7.1 (publisher concurrency):** PASS - `group: ${{ github.workflow }}` ref-independent + `cancel-in-progress: false` (`publish-release.yml:19-21`); rationale documented inline (`:16-18`). -- **D7.2 (permissions):** PASS - `create-release` caller grants `contents: write` at the one entry point (`publish-release.yml:90-91`); `cleanup-artifacts` grants `actions: write` (`:104-105`); `check-ha-version` grants `contents: write`/`pull-requests: write` on the one job (`:24-26`); merge-bot jobs declare least-privilege. -- **D7.3 / D7.4 (booleans / optional-dep chaining):** PASS - the `build` boolean is compared against both forms `inputs.build == true || inputs.build == 'true'` (`test-release-task.yml:240`) and declared in both `workflow_call` and `workflow_dispatch` blocks (`:14-29`); publish jobs allowlist `success`/`skipped` explicitly with `always()` (`publish-release.yml:66-68,83-87`). -- **D8.1 (merge-bot):** PASS - auto-merge on `opened`/`reopened` (`merge-bot-pull-request.yml:28-31`); method by base ref `case` (`develop` -> `--squash`, `main` -> `--merge`, else error) (`:53-60`); disable on maintainer `synchronize` (`:102-124`); concurrency keyed on the PR number (`:18`); `pull_request_target` + App token so merges fire downstream workflows (`:11,43-46`). -- **D8.2 (Dependabot):** PASS - `devcontainers`, `github-actions`, `pip` each duplicated for `main` and `develop`, daily (`dependabot.yml:16-72`); `homeassistant`/`pytest-homeassistant-custom-component`/`pytest-cov` ignored because the HA-version bot owns them (`:45-55,62-72`). -- **D8.3 (upstream-version tracker):** PASS (as a **test-matrix tracker**) - `check-ha-version.yml` is a scheduled resolver (daily, `:4-11`) that writes a JSON state file (`.github/ha-test-versions.json`, `:203-211`, CRLF-forced) and opens a rolling bundled bump PR on branch `ha-version-bump/matrix` -> `develop` (`:301-316`); the merge-bot's `merge-ha-version-bump` job auto-merges any `startsWith(head.ref, 'ha-version-bump/')` PR based on `develop` (`merge-bot-pull-request.yml:71-92`), so **the tracker's branch prefix matches the merge-bot's head-ref guard** (the D8.3 pairing that otherwise silently breaks auto-merge). It reuses the tracker *pattern* but tracks CI versions, not the build tag - see Spec Updates. -- **D9.1 (SHA-pinning):** PASS - all actions SHA-pinned with version comments, including build-layer leaves and HA actions: `hacs/action@d556e73 # 22.5.0` (`test-release-task.yml:228`), `home-assistant/actions/hassfest@f4ca6f6 # master` (`:220`), `codecov/codecov-action@fb8b358 # v7.0.0` (`:204`), `peter-evans/create-pull-request@5f6978f # v8.1.1` (`check-ha-version.yml:301`), `softprops/action-gh-release@718ea10 # v3.0.1` (`build-release-task.yml:134`). `dotnet/nbgv@master` is the sanctioned lagging-tag exception (`get-version-task.yml:44`). -- **D9.5 (line endings):** PASS - see recurring-violations (global CRLF default + LF pins, no mixed files). -- **NuGet / PyPI / Docker / console 5A addenda:** N/A. - -## WORKFLOW.md 5B Trace (applicable scenarios) - -- **S1** (push touching the integration): full `test-release-task` runs (ruff/mypy/pyright/docs/pytest-matrix/hassfest/HACS); `build-release` runs with `github: false`, **uploads** `purpleair-zip` (retention 1), publishes nothing; aggregator success; version prerelease off `develop`. PASS (with the D1.3 upload note). -- **S2 / S3** (docs-only / workflow-only push): full suite **runs** (no paths-filter) instead of skipping; aggregator success - safe but over-builds. DRIFT. -- **S4** (push to `main`, e.g. a promotion): full suite runs; NBGV yields clean `X.Y.Z`; nothing publishes (no push publish trigger); promotion not blocked. PASS. -- **S5 / S6** (push, opt-in un/set): N/A - the publisher has **no push trigger** (`publish-release.yml:8,13-14`); no push ever publishes, stronger than the opt-in-variable model. -- **S7** (dispatch publish): `gate` success -> `test-release` success -> `create-release` stamps `manifest.json` from NBGV, zips files-at-root, asserts layout, creates the tag+release, attaches `purpleair.zip`; `main` dispatch -> stable (`prerelease=false`), `develop` dispatch -> prerelease (`X.Y.Z-g<sha>`); `cleanup-artifacts` blanket-deletes run artifacts. One branch per dispatch (no matrix). PASS outcome / structural drift (D5.5, seam). -- **Schedule** (weekly): `gate` skipped, `test-release` **runs** (retests `main` via `always()` allowlist, `publish-release.yml:66-68`), `create-release` **skipped** (`event != workflow_dispatch`, `:85`) -> retest-only, never publishes. PASS (by design; the publisher-side complement to the daily develop matrix bot). -- **S8** (dispatch from a non-`main`/`develop` ref): `gate` **fails fast** with `::error::` (`publish-release.yml:36-39`); downstream jobs skip. PASS (template-correct). -- **S9** (re-dispatch, version unchanged): same `SemVer2` -> same tag -> softprops updates the existing release in place (no duplicate); no release-exists gate, but a dispatch is a deliberate refresh; no registry to dedupe. PASS (intent). -- **S10** (branch/version disagree): N/A - NBGV derives classification from the checked-out ref with no independent branch input to disagree with. -- **S11** (test-matrix bump): resolver detects a newer stable/beta pytest-hacc/HA pair -> writes `ha-test-versions.json` (+ repins `requirements*.txt`) -> opens `ha-version-bump/matrix` PR -> merge-bot auto-merges -> the new matrix gates the **next** develop run. PASS (as a test-matrix tracker, not a build-version bump). - -## Defects (most severe first) - -None. No applicable check fails both letter and intent. - -## Drift Findings - -1. **`publish-release.yml` blanket-deletes the whole run artifact set - the D5.5 anti-pattern.** `cleanup-artifacts` runs `gh api repos/.../actions/runs/${{ github.run_id }}/artifacts --jq '.artifacts[].id'` and DELETEs every id (`publish-release.yml:114-122`). WORKFLOW.md D5.5 states cleanup MUST NOT enumerate and delete the run's whole artifact set, precisely because it also destroys diagnostic/log artifacts and auto-emitted build-records. Harmless today (the pure-Python pipeline emits no build-records, and `purpleair-zip` already sets `retention-days: 1`, `build-release-task.yml:107`), but it is a letter violation of a MUST-NOT and would be destructive if a Docker/other target is added. Fix: delete `purpleair-zip` by exact name at its consumer (the release job) and rely on the retention backstop, dropping the blanket sweep. -2. **Python ruff/pyright config off-canonical.** ruff lives in `.ruff.toml` and pyright in `pyrightconfig.json`; `pyproject.toml` holds only `[tool.pytest.ini_options]` (`pyproject.toml:1`) and `[tool.pylint]` (`:32`), no `[tool.ruff]`/`[tool.pyright]`. `python.config.placement` letter miss. Intent satisfied (both linters present and CI-run). This is an HA convention (mirrors `home-assistant/core`); see Spec Updates for the profile that would bless it. (driftNote (a) confirmed.) -3. **Em-dash / arrow in Python source comments and docstrings.** U+2014 and U+2192 appear throughout `config_flow.py` (`:120,136,150,187,207,441,459,497,705`), `coordinator.py:140`, `diagnostics.py:43`, `sensor.py`, and `tests/components/purpleair/*` where ASCII equivalents (` - `, `->`) apply. Governance docs (README/HISTORY/AGENTS/CODESTYLE/WORKFLOW) are ASCII-clean, and scientific symbols (U+00B5, U+00B3, U+00B2, U+00A7, U+00B7, U+2212) are the allowed carve-out. U+2264 and U+2265 are not: the charset rule names both must-replace, with `<=` and `>=` as their ASCII forms. A `recurring.charset` letter miss confined to code comments. -4. **Committed rulesets omit fields the live rulesets carry.** `repo-config/ruleset-{develop,main}.json` omit `required_reviewers: []` inside the `pull_request` params (live carries it) and top-level `bypass_actors` (live: `RepositoryRole 5 always` on both). Enforcement intact; required-check `context` matches the aggregator name. (Recurs with VSCode-Server / LanguageTags / ESPHome-NonRoot.) -5. **repo-config filenames off-baseline.** Committed as `repo-config/ruleset-{develop,main}.json`; `spec/files.json:15-16` expects `repo-config/develop.json` / `main.json`. (Recurs with Utilities / PlexCleaner / LanguageTags / ESPHome-NonRoot / VSCode-Server.) -6. **Extra live "Tag Rules" ruleset unrepresented in `repo-config`.** A third active ruleset (`target: tag`, rules `deletion` + `update`) protects release tags but has no committed counterpart under `repo-config/`. A useful protection; off the two-ruleset baseline. -7. **No `changes` paths-filter; every push runs the full suite.** `test-pull-request.yml:11-12` self-tests every branch with no `dorny/paths-filter`, so docs-only and workflow-only pushes run ruff/mypy/pyright/the full pytest matrix/hassfest/HACS. Over-builds. (Recurs across the fleet.) -8. **NBGV without `IGNORE_GITHUB_REF`; no separate release-classification gate.** `get-version-task.yml:40-44` runs NBGV with no `IGNORE_GITHUB_REF=true`; classification is derived from the checked-out ref (`:62-67`). Correct only because one branch is built per dispatch. (Recurs with PlexCleaner / LanguageTags / ESPHome-NonRoot / VSCode-Server.) -9. **`target_commitish: github.sha`, not NBGV `GitCommitId`.** `build-release-task.yml:137` tags off `github.sha`; the template forbids this in a branch-matrix publisher. Safe here (dispatch runs from the actual branch tip = built commit); a future matrix would mistag. -10. **Bespoke release seam.** `purpleair-zip` uploaded/downloaded by `name:` (`build-release-task.yml:101-121`), not `release-asset-<branch>-*` `pattern:`/`merge-multiple:`, and no `expect_release_assets`. Equivalent outcome; forks the verbatim `github-release` handoff. -11. **PR path uploads an artifact instead of `!smoke`-gating uploads.** The PR-triggered `build-release` uploads `purpleair-zip` (`build-release-task.yml:101-107`); the template uploads nothing on smoke. Mitigated by `retention-days: 1` and no PR-path cleanup. -12. **`develop` is 34 behind `main`** (ahead 0). See Develop Drift. - -## Proposed Registry / Spec Updates - -- **Registry - fix the stale driftNote (verified).** driftNote (b) "cspell.json missing" is **stale**: `cspell.json` exists with `"language": "en-US"` and a full word list (`cspell.json:1-84`) and CI runs it (`test-release-task.yml:113-119`). Remove it. driftNote (a) (ruff/pyright standalone) is accurate; keep it, or retire it once the HA Python profile (below) is added and blesses the standalone configs. `types ["python","homeassistant"]`, `publish [{github-release, none}]`, `consumerModel push`, `releaseTrigger dispatch-only`, `pythonConfig standalone` all verified accurate. Consider recording the extra "Tag Rules" ruleset and the `check-ha-version` test-matrix tracker. -- **Spec gap - HA-integration Python profile (the deferred profile).** `spec/project-types.json` `python` sets `canonicalPlacement: pyproject.toml`, and `CODESTYLE.md` prescribes uv/hatchling/`src` layout and "mypy is not used - don't introduce it." HA integrations follow `home-assistant/core`: pip + `requirements*.txt`, a `custom_components/<domain>/` layout (no `src/`, no `uv.lock`), a `.ruff.toml` derived from core, `pyrightconfig.json`, and `mypy --strict` (required by the platinum `strict-typing` quality-scale rule, `test-release-task.yml:71-73`). Under the current spec this repo scores a `python.config.placement` drift and a CODESTYLE "no mypy" conflict for doing the correct HA thing. Add an HA Python profile so standalone ruff/pyright configs and `mypy --strict` are expected, not drift. **Partly landed in hub #245** (the strong-typing policy records the default-vs-HA profile split, exempts HA standalone configs, and expects `mypy --strict`); the fuller `homeassistant`-type checks below remain. -- **Spec gap - flesh out the `homeassistant` type.** It has only `ha.release.only`. Add checks for `manifest.json` (`domain`, a `0.0.0` version placeholder stamped from NBGV at build, `requirements` incl. the pinned client, `iot_class`, `codeowners`, `quality_scale`), `hacs.json` (`zip_release` + `filename`), `quality_scale.yaml`, `strings.json` + `translations/`, `icons.json`, `brand/*` assets, the hassfest + HACS validation jobs, and the **files-at-root zip layout** HACS requires (asserted at `build-release-task.yml:68-99`). All present and correct here; the spec just cannot yet score them. -- **Spec gap - the upstream test-matrix tracker.** `check-ha-version.yml` reuses the D8.3 tracker pattern (scheduled resolver -> JSON `name->version` state file -> rolling per-branch bump PR -> merge-bot auto-merge, branch prefix matched to the merge-bot guard) but tracks a **CI test matrix**, not a build-pinning version - the build still tags off NBGV, so it is neither the D3.5 `upstream-wrapper` nor `codegen`. Its state file correctly sits under `.github/ha-test-versions.json` (CI config, unlike the repo-root `upstream-version.json` build-input). Recognize this "test-matrix tracker" so a fleet HA repo is not forced into the `upstream-wrapper` misfit. -- **Spec signal - D5.5 blanket-delete pattern.** A machine check could flag the `gh api .../artifacts --jq '.artifacts[].id'` blanket-delete shape (here in `publish-release.yml:114-122`) as a D5.5 violation regardless of current harmlessness, steering repos to surgical delete-at-consumption + the `retention-days: 1` backstop. -- **Recurring cross-fleet drifts confirmed here** (off-baseline `repo-config/ruleset-*.json` filenames, committed rulesets omitting `bypass_actors`/`required_reviewers`, no `changes` paths-filter, NBGV without `IGNORE_GITHUB_REF`, `github.sha` tag target). Notably this repo does **not** exhibit the missing global `[*] end_of_line = crlf` default (it has one, `.editorconfig:13`) and does **not** exhibit the skip-not-fail dispatch guard (its `gate` fails fast, `publish-release.yml:36-39`) - two fleet drifts it gets right. +# Audit: homeassistant-purpleair + +- **Audited branch:** main (`a56cac0be951e5665f48b8d23c8e9e382c51feb5`) +- **Types:** python, homeassistant (from registry) +- **Verdict:** operational +- **Date:** 2026-07-05 + +## Develop Drift + +`develop` vs `main`: ahead 0, behind 34 (`gh api repos/ptr727/homeassistant-purpleair/compare/main...develop` -> `status: behind, ahead_by: 0, behind_by: 34`). **Stale - a drift finding.** `develop` carries nothing `main` lacks and is 34 commits behind. Under the forward-only model the accumulated `main` work (releases, Dependabot, doc edits) has not been carried down to `develop`. No release impact: the publisher is dispatch-only (`publish-release.yml:8`), the daily HA-matrix bot opens its PRs against `develop` directly (`check-ha-version.yml:42-44,310`), and merges never publish - but the two branches should be reconciled forward-only. + +## Dimensions + +| Dimension | Letter | Intent | Verdict | Evidence (file:line) | +| --- | --- | --- | --- | --- | +| python | drift | pass | drift | ruff present (`.ruff.toml:7-171`), pyright present (`pyrightconfig.json:1-7`), mypy `--strict` added for HA platinum `strict-typing` (`test-release-task.yml:71-73`). **Letter miss:** ruff/pyright are NOT in `pyproject.toml`, which carries only `[tool.pytest.ini_options]` (`pyproject.toml:1`) and `[tool.pylint]` (`:32`). The whole toolchain (pip + `requirements*.txt`, `custom_components/` layout, no uv/hatchling/src) mirrors `home-assistant/core`, not the fleet Python profile - see Spec Updates | +| homeassistant | pass | pass | pass | HACS push distribution is a GitHub release only, no registry (`publish-release.yml:94` `github: true`, no PyPI/Docker/NuGet). `manifest.json` well-formed: `domain: purpleair` (`:2`), `version: "0.0.0"` placeholder stamped from NBGV at build (`build-release-task.yml:30-43`), `requirements: ["ptr727-aiopurpleair==1.0.7"]` (`:17-19`), `iot_class: cloud_polling` (`:10`), `codeowners: ["@ptr727"]` (`:4-6`), `quality_scale: platinum` (`:16`). `hacs.json` `zip_release: true` + `filename: purpleair.zip` (`:5-6`), `homeassistant: 2026.4.0` matches `ha-test-versions.json:4` minimum and the README shield. `quality_scale.yaml`, `strings.json`, `translations/en.json`, `icons.json`, `brand/*.png` all present; hassfest (`test-release-task.yml:220`) + HACS (`:228`) validated in CI. No formal spec checks for these - see Spec Updates | +| nuget | - | - | N/A | no `dotnet nuget push` / `*.csproj` | +| pypi | - | - | N/A | not published to PyPI; the integration ships via HACS (a GitHub-release zip HACS extracts into `<config>/custom_components/`). No `build-pypilibrary-task` / `gh-action-pypi-publish`; `requirements*.txt` are dev/CI pins, not a distributed package. `_version.py`/OIDC/`environment: pypi` clauses do not apply | +| console | - | - | N/A | no executable build task / `System.CommandLine` | +| docker | - | - | N/A | no `Dockerfile` / `build-docker-task.yml` | +| branch-model | drift | pass | drift | both branches protected, live rulesets `enforcement: active`; develop allows only `squash` + `required_linear_history`, main allows only `merge` (`ruleset-{develop,main}.json`), required check `context: "Check pull request workflow status job"` matches the aggregator `name:` (`test-pull-request.yml:34`). Normalized diff: committed rulesets omit `required_reviewers: []` (live carries it) and top-level `bypass_actors` (live: `RepositoryRole 5 always` on both). Off-baseline filenames; extra live "Tag Rules" ruleset unrepresented - see Drift | +| repo-setup | pass | pass | pass | actions + dependabot stores both hold `CODEGEN_APP_CLIENT_ID`/`CODEGEN_APP_PRIVATE_KEY` (baseline merge-bot App, `merge-bot-pull-request.yml:45-46`) and `CODECOV_TOKEN` (codecov mechanism, used at `test-release-task.yml:204-207`); no forbidden `NUGET_API_KEY`/`PYPI_API_TOKEN`/`CODEGEN_APP_ID`; no publish secrets (github-release needs none). `CODECOV_TOKEN` in the dependabot store is beyond the mechanism's `stores: ["actions"]` but harmless | +| linter-parity | pass | pass | pass | one config each drives editor+CLI+CI, and CI runs each: markdownlint via `.markdownlint-cli2.jsonc` (`test-release-task.yml:108-110`), cspell via `cspell.json` `language: en-US` (`:113-119`), ruff via `.ruff.toml` (`:49-53`), pyright via `pyrightconfig.json` (`:91-95`), actionlint (`:121-122`), shellcheck (`:125-126`), plus mypy `--strict` (`:71-73`) | +| recurring-violations | drift | pass | drift | comments concise; spelling `en-US` (`cspell.json:3`); **EOL clean** - global `[*] end_of_line = crlf` default present (`.editorconfig:13`), LF pins for `*.sh`/`scripts/*` (`:29-33`) mirrored in `.gitattributes:12-13`, byte scan of all tracked files shows 68 CRLF / 6 correctly-LF (`scripts/*`, `repo-config/configure.sh`) / **0 mixed**. **Letter miss (charset):** em-dash (U+2014) and arrow (U+2192) pervade Python source comments/docstrings (`config_flow.py:120,136,...`, `coordinator.py:140`, `sensor.py`, `tests/*`) where ASCII equivalents (` - `, `->`) apply; docs are ASCII-clean; scientific symbols (U+00B5, U+00B3, U+00B2, U+00A7, U+00B7, U+2212) are within the carve-out, and U+2264/U+2265 are named must-replace by the charset rule. See Drift | +| readme-structure | pass | pass | pass | strongly canonical: Title + one-line desc (`README.md:1-3`); `## Build and Distribution` parent (`:5`) with `### Build Status` (`:7`), `### Releases` (`:16`), `### Release Notes` (`:21`); `## Table of Contents` (`:39`, MAiO inline anchors); `## Installation` (`:92`), `## Configuration` (`:106`), `## Questions or Issues` (`:280`), `## Contributing` covering dev setup (`:289`), `## Credits` (`:303`), `## License` last (`:310`); reference-style links defined at bottom (`:317+`). Minor: `## Features` in place of `## Use Cases` (intent-equivalent); no standalone "Development Environment Setup" heading (Contributing points to `DEVCONTAINER.md`) | +| workflow (WORKFLOW.md 5A/5B) | drift | pass | drift | all applicable D-guarantees hold by outcome; divergences are structural (see 5A/5B): D5.5 blanket artifact delete, no `changes` paths-filter, NBGV without `IGNORE_GITHUB_REF`, `target_commitish: github.sha`, bespoke non-`release-asset-*` seam, no release-exists no-op gate. The dispatch-ref guard is a **correct fail-fast** (not the recurring skip drift) | + +## WORKFLOW.md 5A Static Audit (applicable D-guarantees) + +The pipeline is deliberately reimagined for HA/HACS: `push:['**']` self-tests every branch (`test-pull-request.yml:11-12`), `workflow_dispatch` from `main`/`develop` publishes (`publish-release.yml:8,74-94`), and the weekly `schedule` **retests `main` and stops** (`:13-14,77`). One dispatch = one branch = one ref, so there is **no branch matrix**; the template guards that defend a matrix publisher against building the wrong branch from the default ref are structurally unnecessary here. + +- **D1.1 / D1.4 (paths-filter):** DRIFT - no `changes`/`dorny/paths-filter` job anywhere (`grep` -> none). Every push runs the full `test-release-task` suite, including docs-only and workflow-only pushes. Over-builds; with a single always-built integration the "changed target slips unbuilt" failure cannot occur. (Recurs across the fleet.) +- **D1.2 (validation always runs):** PASS - `test-release-task.yml` runs ruff/mypy/pyright/docs-lint/pytest-matrix/hassfest/HACS unconditionally on every push; the aggregator `needs: [test-release]` (`test-pull-request.yml:36`) and fails on any non-`success` (`:43-45`). +- **D1.3 (smoke never publishes):** PASS (publish) / DRIFT (upload) - the PR path calls `test-release-task` with `build` defaulting true, so `build-release` runs with `github: false` (`test-release-task.yml:246`) - no release. But the build job **uploads** `purpleair-zip` on the PR path (`build-release-task.yml:101-107`); the template gates all uploads `!smoke`. Mitigated by `retention-days: 1` (`:107`) and no PR-path cleanup; the artifact is reaped within a day. +- **D1.5 (one aggregator):** PASS - `Check pull request workflow status job`, `needs: [test-release]`, `always() && !github.event.deleted`, fails on non-`success` (`test-pull-request.yml:33-45`). Name is the ruleset-bound context. +- **D2.2 (release branch vs classification):** N/A (no separate branch input) - NBGV derives `Prerelease` solely from the `-` in `SemVer2` (`get-version-task.yml:62-67`); `main` is the public ref (`version.json:4-6` `^refs/heads/main$`), so a `main` dispatch -> clean `X.Y.Z`, a `develop` dispatch -> `X.Y.Z-g<sha>`. There is no independent branch input for the ref to disagree with, so the template's two-direction `validate-release` gate has nothing to guard. +- **D2.3 (dispatch guard):** **PASS (fail-fast)** - the `gate` job asserts the dispatch ref is `main` or `develop` and **fails fast** with `::error::` + `exit 1` on any other ref (`publish-release.yml:33-39`). This is the template-correct form, NOT the recurring skip-not-fail drift. +- **D3.1 (version from checked-out branch):** DRIFT (letter) / PASS (intent) - NBGV runs once in `get-version` (`get-version-task.yml:40-44`) with **no** `IGNORE_GITHUB_REF=true`. Correct because each dispatch checks out its own ref (no matrix building `develop` from a `main`-ref run); a future both-branch matrix would misclassify. (Recurs cross-fleet.) +- **D3.2 (default = public):** PASS - `version.json:4-6` `publicReleaseRefSpec ^refs/heads/main$`; prerelease detection by `SemVer2` containing `-` (`get-version-task.yml:62-67`). +- **D3.3 (floor + git height):** PASS - `version.json:3` `version "1.0"`; NBGV appends git height. Retained though no compiled code (owns the release tag). `versionHeightOffset: -1` (`:10`). +- **D3.4 (registry version per classification):** N/A - no package registry; the release zip carries the NBGV `SemVer2`-stamped `manifest.json` (`build-release-task.yml:37-43`). +- **D3.5 (wrapper external version):** N/A - **not a build-version wrapper.** `check-ha-version.yml` tracks a CI **test matrix** (`.github/ha-test-versions.json`), not the build tag; the release tags off NBGV `SemVer2` (`build-release-task.yml:136`). See the tracker note under D8.3 and Spec Updates. +- **D4.1 (two-phase / merges never publish):** PASS (stronger) - the publisher has **no push trigger** at all (`publish-release.yml:8,13-14`); merges never publish and there is no `PUBLISH_ON_MERGE` variable (none configured). Publishing is a deliberate dispatch. Not a both-branch matrix (one branch per dispatch), but the HACS push-consumer model makes release restraint the point. +- **D4.2 (tag the built commit):** DRIFT (letter) / PASS (intent) - `target_commitish: github.sha` (`build-release-task.yml:137`), NOT NBGV `GitCommitId`. Safe because a dispatch runs from the actual branch tip and `get-version`/`build` both check out `github.sha`, so it IS the built commit; the template forbids `github.sha` specifically because its branch-matrix `develop` leg runs with `github.sha` = main's tip - a case that cannot arise here. +- **D4.3 (release contents):** PASS (intent) - tag + `generate_release_notes` + the `purpleair.zip` asset (`build-release-task.yml:134-141`), `prerelease` = `Prerelease == 'true'` (`:138`), `fail_on_unmatched_files: true` (`:140`). Uses a plain `name:`-based upload/download of `purpleair-zip` rather than the template's `release-asset-<branch>-*` pattern + `expect_release_assets`; equivalent tag+asset outcome, forks the seam (single-branch-per-run needs no branch suffix). +- **D4.4 (no-op republish):** DRIFT (letter) / PASS (intent) - **no** `release-exists`/`workflow_dispatch` gate on the create step; a re-dispatch with unchanged `SemVer2` reuses the same tag, so softprops updates the existing release in place (no duplicate). Publishing is a deliberate dispatch act, so an intentional refresh is acceptable; no NuGet/PyPI/Docker to dedupe. +- **D5.1-D5.4 (cleanup at consumption / retention):** DRIFT - the sole upload sets `retention-days: 1` (`build-release-task.yml:107`) (D5.4 PASS), but there is **no** surgical delete-at-consumption; cleanup is deferred to a terminal job. +- **D5.5 (never blanket-delete):** **DRIFT (letter miss of a MUST-NOT) / PASS (intent)** - `cleanup-artifacts` enumerates and deletes the **whole run artifact set** via `gh api .../artifacts --jq '.artifacts[].id'` (`publish-release.yml:114-122`) - the exact anti-pattern D5.5 forbids. The failure mode (destroying diagnostic/build-record artifacts) does not materialize because the pure-Python pipeline emits none and `purpleair-zip` already carries `retention-days: 1`; adding a Docker/other target that emits build-records would make this destructive. Ranked #1 in Drift. +- **D6.1 / D6.2 / D6.3 (seam):** DRIFT (letter) / PASS (intent) - artifact is `purpleair-zip`, downloaded by `name:` (`build-release-task.yml:117-121`), not a `release-asset-<branch>-*` `pattern:`/`merge-multiple:` handoff, and not branch-suffixed. Correct for a single-artifact, single-branch-per-run model; branch-derived classification keys off NBGV/`version.json`, never `github.ref_name`. +- **D7.1 (publisher concurrency):** PASS - `group: ${{ github.workflow }}` ref-independent + `cancel-in-progress: false` (`publish-release.yml:19-21`); rationale documented inline (`:16-18`). +- **D7.2 (permissions):** PASS - `create-release` caller grants `contents: write` at the one entry point (`publish-release.yml:90-91`); `cleanup-artifacts` grants `actions: write` (`:104-105`); `check-ha-version` grants `contents: write`/`pull-requests: write` on the one job (`:24-26`); merge-bot jobs declare least-privilege. +- **D7.3 / D7.4 (booleans / optional-dep chaining):** PASS - the `build` boolean is compared against both forms `inputs.build == true || inputs.build == 'true'` (`test-release-task.yml:240`) and declared in both `workflow_call` and `workflow_dispatch` blocks (`:14-29`); publish jobs allowlist `success`/`skipped` explicitly with `always()` (`publish-release.yml:66-68,83-87`). +- **D8.1 (merge-bot):** PASS - auto-merge on `opened`/`reopened` (`merge-bot-pull-request.yml:28-31`); method by base ref `case` (`develop` -> `--squash`, `main` -> `--merge`, else error) (`:53-60`); disable on maintainer `synchronize` (`:102-124`); concurrency keyed on the PR number (`:18`); `pull_request_target` + App token so merges fire downstream workflows (`:11,43-46`). +- **D8.2 (Dependabot):** PASS - `devcontainers`, `github-actions`, `pip` each duplicated for `main` and `develop`, daily (`dependabot.yml:16-72`); `homeassistant`/`pytest-homeassistant-custom-component`/`pytest-cov` ignored because the HA-version bot owns them (`:45-55,62-72`). +- **D8.3 (upstream-version tracker):** PASS (as a **test-matrix tracker**) - `check-ha-version.yml` is a scheduled resolver (daily, `:4-11`) that writes a JSON state file (`.github/ha-test-versions.json`, `:203-211`, CRLF-forced) and opens a rolling bundled bump PR on branch `ha-version-bump/matrix` -> `develop` (`:301-316`); the merge-bot's `merge-ha-version-bump` job auto-merges any `startsWith(head.ref, 'ha-version-bump/')` PR based on `develop` (`merge-bot-pull-request.yml:71-92`), so **the tracker's branch prefix matches the merge-bot's head-ref guard** (the D8.3 pairing that otherwise silently breaks auto-merge). It reuses the tracker *pattern* but tracks CI versions, not the build tag - see Spec Updates. +- **D9.1 (SHA-pinning):** PASS - all actions SHA-pinned with version comments, including build-layer leaves and HA actions: `hacs/action@d556e73 # 22.5.0` (`test-release-task.yml:228`), `home-assistant/actions/hassfest@f4ca6f6 # master` (`:220`), `codecov/codecov-action@fb8b358 # v7.0.0` (`:204`), `peter-evans/create-pull-request@5f6978f # v8.1.1` (`check-ha-version.yml:301`), `softprops/action-gh-release@718ea10 # v3.0.1` (`build-release-task.yml:134`). `dotnet/nbgv@master` is the sanctioned lagging-tag exception (`get-version-task.yml:44`). +- **D9.5 (line endings):** PASS - see recurring-violations (global CRLF default + LF pins, no mixed files). +- **NuGet / PyPI / Docker / console 5A addenda:** N/A. + +## WORKFLOW.md 5B Trace (applicable scenarios) + +- **S1** (push touching the integration): full `test-release-task` runs (ruff/mypy/pyright/docs/pytest-matrix/hassfest/HACS); `build-release` runs with `github: false`, **uploads** `purpleair-zip` (retention 1), publishes nothing; aggregator success; version prerelease off `develop`. PASS (with the D1.3 upload note). +- **S2 / S3** (docs-only / workflow-only push): full suite **runs** (no paths-filter) instead of skipping; aggregator success - safe but over-builds. DRIFT. +- **S4** (push to `main`, e.g. a promotion): full suite runs; NBGV yields clean `X.Y.Z`; nothing publishes (no push publish trigger); promotion not blocked. PASS. +- **S5 / S6** (push, opt-in un/set): N/A - the publisher has **no push trigger** (`publish-release.yml:8,13-14`); no push ever publishes, stronger than the opt-in-variable model. +- **S7** (dispatch publish): `gate` success -> `test-release` success -> `create-release` stamps `manifest.json` from NBGV, zips files-at-root, asserts layout, creates the tag+release, attaches `purpleair.zip`; `main` dispatch -> stable (`prerelease=false`), `develop` dispatch -> prerelease (`X.Y.Z-g<sha>`); `cleanup-artifacts` blanket-deletes run artifacts. One branch per dispatch (no matrix). PASS outcome / structural drift (D5.5, seam). +- **Schedule** (weekly): `gate` skipped, `test-release` **runs** (retests `main` via `always()` allowlist, `publish-release.yml:66-68`), `create-release` **skipped** (`event != workflow_dispatch`, `:85`) -> retest-only, never publishes. PASS (by design; the publisher-side complement to the daily develop matrix bot). +- **S8** (dispatch from a non-`main`/`develop` ref): `gate` **fails fast** with `::error::` (`publish-release.yml:36-39`); downstream jobs skip. PASS (template-correct). +- **S9** (re-dispatch, version unchanged): same `SemVer2` -> same tag -> softprops updates the existing release in place (no duplicate); no release-exists gate, but a dispatch is a deliberate refresh; no registry to dedupe. PASS (intent). +- **S10** (branch/version disagree): N/A - NBGV derives classification from the checked-out ref with no independent branch input to disagree with. +- **S11** (test-matrix bump): resolver detects a newer stable/beta pytest-hacc/HA pair -> writes `ha-test-versions.json` (+ repins `requirements*.txt`) -> opens `ha-version-bump/matrix` PR -> merge-bot auto-merges -> the new matrix gates the **next** develop run. PASS (as a test-matrix tracker, not a build-version bump). + +## Defects (most severe first) + +None. No applicable check fails both letter and intent. + +## Drift Findings + +1. **`publish-release.yml` blanket-deletes the whole run artifact set - the D5.5 anti-pattern.** `cleanup-artifacts` runs `gh api repos/.../actions/runs/${{ github.run_id }}/artifacts --jq '.artifacts[].id'` and DELETEs every id (`publish-release.yml:114-122`). WORKFLOW.md D5.5 states cleanup MUST NOT enumerate and delete the run's whole artifact set, precisely because it also destroys diagnostic/log artifacts and auto-emitted build-records. Harmless today (the pure-Python pipeline emits no build-records, and `purpleair-zip` already sets `retention-days: 1`, `build-release-task.yml:107`), but it is a letter violation of a MUST-NOT and would be destructive if a Docker/other target is added. Fix: delete `purpleair-zip` by exact name at its consumer (the release job) and rely on the retention backstop, dropping the blanket sweep. +2. **Python ruff/pyright config off-canonical.** ruff lives in `.ruff.toml` and pyright in `pyrightconfig.json`; `pyproject.toml` holds only `[tool.pytest.ini_options]` (`pyproject.toml:1`) and `[tool.pylint]` (`:32`), no `[tool.ruff]`/`[tool.pyright]`. `python.config.placement` letter miss. Intent satisfied (both linters present and CI-run). This is an HA convention (mirrors `home-assistant/core`); see Spec Updates for the profile that would bless it. (driftNote (a) confirmed.) +3. **Em-dash / arrow in Python source comments and docstrings.** U+2014 and U+2192 appear throughout `config_flow.py` (`:120,136,150,187,207,441,459,497,705`), `coordinator.py:140`, `diagnostics.py:43`, `sensor.py`, and `tests/components/purpleair/*` where ASCII equivalents (` - `, `->`) apply. Governance docs (README/HISTORY/AGENTS/CODESTYLE/WORKFLOW) are ASCII-clean, and scientific symbols (U+00B5, U+00B3, U+00B2, U+00A7, U+00B7, U+2212) are the allowed carve-out. U+2264 and U+2265 are not: the charset rule names both must-replace, with `<=` and `>=` as their ASCII forms. A `recurring.charset` letter miss confined to code comments. +4. **Committed rulesets omit fields the live rulesets carry.** `repo-config/ruleset-{develop,main}.json` omit `required_reviewers: []` inside the `pull_request` params (live carries it) and top-level `bypass_actors` (live: `RepositoryRole 5 always` on both). Enforcement intact; required-check `context` matches the aggregator name. (Recurs with VSCode-Server / LanguageTags / ESPHome-NonRoot.) +5. **repo-config filenames off-baseline.** Committed as `repo-config/ruleset-{develop,main}.json`; `spec/files.json:15-16` expects `repo-config/develop.json` / `main.json`. (Recurs with Utilities / PlexCleaner / LanguageTags / ESPHome-NonRoot / VSCode-Server.) +6. **Extra live "Tag Rules" ruleset unrepresented in `repo-config`.** A third active ruleset (`target: tag`, rules `deletion` + `update`) protects release tags but has no committed counterpart under `repo-config/`. A useful protection; off the two-ruleset baseline. +7. **No `changes` paths-filter; every push runs the full suite.** `test-pull-request.yml:11-12` self-tests every branch with no `dorny/paths-filter`, so docs-only and workflow-only pushes run ruff/mypy/pyright/the full pytest matrix/hassfest/HACS. Over-builds. (Recurs across the fleet.) +8. **NBGV without `IGNORE_GITHUB_REF`; no separate release-classification gate.** `get-version-task.yml:40-44` runs NBGV with no `IGNORE_GITHUB_REF=true`; classification is derived from the checked-out ref (`:62-67`). Correct only because one branch is built per dispatch. (Recurs with PlexCleaner / LanguageTags / ESPHome-NonRoot / VSCode-Server.) +9. **`target_commitish: github.sha`, not NBGV `GitCommitId`.** `build-release-task.yml:137` tags off `github.sha`; the template forbids this in a branch-matrix publisher. Safe here (dispatch runs from the actual branch tip = built commit); a future matrix would mistag. +10. **Bespoke release seam.** `purpleair-zip` uploaded/downloaded by `name:` (`build-release-task.yml:101-121`), not `release-asset-<branch>-*` `pattern:`/`merge-multiple:`, and no `expect_release_assets`. Equivalent outcome; forks the verbatim `github-release` handoff. +11. **PR path uploads an artifact instead of `!smoke`-gating uploads.** The PR-triggered `build-release` uploads `purpleair-zip` (`build-release-task.yml:101-107`); the template uploads nothing on smoke. Mitigated by `retention-days: 1` and no PR-path cleanup. +12. **`develop` is 34 behind `main`** (ahead 0). See Develop Drift. + +## Proposed Registry / Spec Updates + +- **Registry - fix the stale driftNote (verified).** driftNote (b) "cspell.json missing" is **stale**: `cspell.json` exists with `"language": "en-US"` and a full word list (`cspell.json:1-84`) and CI runs it (`test-release-task.yml:113-119`). Remove it. driftNote (a) (ruff/pyright standalone) is accurate; keep it, or retire it once the HA Python profile (below) is added and blesses the standalone configs. `types ["python","homeassistant"]`, `publish [{github-release, none}]`, `consumerModel push`, `releaseTrigger dispatch-only`, `pythonConfig standalone` all verified accurate. Consider recording the extra "Tag Rules" ruleset and the `check-ha-version` test-matrix tracker. +- **Spec gap - HA-integration Python profile (the deferred profile).** `spec/project-types.json` `python` sets `canonicalPlacement: pyproject.toml`, and `CODESTYLE.md` prescribes uv/hatchling/`src` layout and "mypy is not used - don't introduce it." HA integrations follow `home-assistant/core`: pip + `requirements*.txt`, a `custom_components/<domain>/` layout (no `src/`, no `uv.lock`), a `.ruff.toml` derived from core, `pyrightconfig.json`, and `mypy --strict` (required by the platinum `strict-typing` quality-scale rule, `test-release-task.yml:71-73`). Under the current spec this repo scores a `python.config.placement` drift and a CODESTYLE "no mypy" conflict for doing the correct HA thing. Add an HA Python profile so standalone ruff/pyright configs and `mypy --strict` are expected, not drift. **Partly landed in hub #245** (the strong-typing policy records the default-vs-HA profile split, exempts HA standalone configs, and expects `mypy --strict`); the fuller `homeassistant`-type checks below remain. +- **Spec gap - flesh out the `homeassistant` type.** It has only `ha.release.only`. Add checks for `manifest.json` (`domain`, a `0.0.0` version placeholder stamped from NBGV at build, `requirements` incl. the pinned client, `iot_class`, `codeowners`, `quality_scale`), `hacs.json` (`zip_release` + `filename`), `quality_scale.yaml`, `strings.json` + `translations/`, `icons.json`, `brand/*` assets, the hassfest + HACS validation jobs, and the **files-at-root zip layout** HACS requires (asserted at `build-release-task.yml:68-99`). All present and correct here; the spec just cannot yet score them. +- **Spec gap - the upstream test-matrix tracker.** `check-ha-version.yml` reuses the D8.3 tracker pattern (scheduled resolver -> JSON `name->version` state file -> rolling per-branch bump PR -> merge-bot auto-merge, branch prefix matched to the merge-bot guard) but tracks a **CI test matrix**, not a build-pinning version - the build still tags off NBGV, so it is neither the D3.5 `upstream-wrapper` nor `codegen`. Its state file correctly sits under `.github/ha-test-versions.json` (CI config, unlike the repo-root `upstream-version.json` build-input). Recognize this "test-matrix tracker" so a fleet HA repo is not forced into the `upstream-wrapper` misfit. +- **Spec signal - D5.5 blanket-delete pattern.** A machine check could flag the `gh api .../artifacts --jq '.artifacts[].id'` blanket-delete shape (here in `publish-release.yml:114-122`) as a D5.5 violation regardless of current harmlessness, steering repos to surgical delete-at-consumption + the `retention-days: 1` backstop. +- **Recurring cross-fleet drifts confirmed here** (off-baseline `repo-config/ruleset-*.json` filenames, committed rulesets omitting `bypass_actors`/`required_reviewers`, no `changes` paths-filter, NBGV without `IGNORE_GITHUB_REF`, `github.sha` tag target). Notably this repo does **not** exhibit the missing global `[*] end_of_line = crlf` default (it has one, `.editorconfig:13`) and does **not** exhibit the skip-not-fail dispatch guard (its `gate` fails fast, `publish-release.yml:36-39`) - two fleet drifts it gets right. diff --git a/reports/homeautomation-config/audit.md b/reports/homeautomation-config/audit.md index a705896a..40eb565e 100644 --- a/reports/homeautomation-config/audit.md +++ b/reports/homeautomation-config/audit.md @@ -1,57 +1,57 @@ -# Audit: HomeAutomation-Config - -- **Audited branch:** main (`22ed137`), after the promotion of `develop` (`c8252c7`) -- **Types:** source-only (from registry), operational workflow model -- **Verdict:** operational on every mechanized check and every hand-judged dimension -- **Date:** 2026-08-15 -- **Run stamp:** `audit run 2026-08-15T15:19:01Z | hub 704223a`, with the pre-promotion pair `audit run 2026-08-15T14:58:02Z | hub d54862a` (main at `e10a2cf`) and `audit run 2026-08-15T14:58:25Z | hub d54862a | branch override develop` kept for the record - -First committed report for this repository. It was resynced against the hub's `main` (`0e84805`) on this date per `RESYNC.md`, the resync landed on `develop` as ptr727/HomeAutomation-Config#51, and the promotion to `main` merged as ptr727/HomeAutomation-Config#52. The pre-promotion runs read `main` at `e10a2cf` with the whole stale-carried-content class open and `develop` clean, and the post-promotion run at `22ed137` reads what `develop` read, so the two branches now grade the same. - -## Develop Drift - -`develop` vs `main`: identical trees after the #52 promotion merge (15 content commits since #36, conflict-free). The commit-count gap in `main`'s favor is the promotion-merge ancestry artifact and is **benign**. No action. - -## Dimensions - -| Dimension | Letter | Intent | Verdict | Evidence (file:line) | -| --- | --- | --- | --- | --- | -| source-only (`sourceonly.release.tagonly`) | pass | pass | pass | `.github/workflows/publish-release.yml:61-69` inlines `softprops/action-gh-release` (SHA-pinned) with `LICENSE` and `README.md` as the only files, so the release is the tag, GitHub's source archive, README, and LICENSE. No `build-*-task.yml` and no `expect_release_assets` | -| source-only (`sourceonly.nbgv.retained`) | pass | pass | pass | `version.json` retained (hub byte form after #51), and `dotnet/nbgv@master` computes the tag inline at `.github/workflows/publish-release.yml:55`, the same unpinned form the hub's own publisher and catalog snippet use by design | -| branch-model | pass | pass | pass | Both rulesets live and matching the carried payloads by normalized diff (`repo-config/main.json`, `repo-config/operational/develop.json`), confirmed by `repo-config/configure.sh check ptr727/HomeAutomation-Config operational` from this checkout and by the repository's own `AUDIT.md` snippets, all reporting in sync | -| carried-scope | pass | pass | pass | `repo-config/configure.sh` deleted in #40, no `retire`-dispositioned hub path remains. Two `investigate`-dispositioned hits stand (`.github/workflows/publish-release.yml`, `.github/workflows/validate-task.yml`), which is a hub triage rather than a repository finding, see Escalations | -| repo-setup | pass | pass | pass | `spec/secrets.json` carries `baseline` plus `note` only, the shape `docs/repo-config-carry.md` states for a source-only repository whose only publish target maps to no mechanism (the dead `targetMechanisms` map dropped in #51). `configure.sh check` reports the Dependabot security features enabled | -| linter-parity | pass | pass | pass | `.github/workflows/validate-task.yml:30-50` runs markdownlint-cli2, cspell (README and HISTORY scope), actionlint, and editorconfig-checker as pinned action wrappers or Docker, one config per linter at the root. `.github/workflows/test-pull-request.yml:31` carries the ruleset-bound `Check pull request workflow status job` | -| recurring-violations | pass | pass | pass | Hub prose lint over the resync diff is clean. LF throughout, per the registry `lineEndings: lf` and the repository's `.editorconfig`. Three pre-existing whole-tree `dead-path` mentions remain (`GOVERNANCE.md:29`, `GOVERNANCE.md:282`, `OPERATIONS.md:306`), the first two inside carried text and all three left for the files' next edit per the fix-as-edited policy | -| readme-structure | pass | pass | pass | The seven README letters the pre-promotion `main` run reported (license shield placement, `-link` suffixes, `github-link` naming, group membership) were closed in #40 and the post-promotion run reports none | -| agent-instruction-set | pass | pass | pass | Every `AGENTS.md` and `GOVERNANCE.md` verbatim section matches the current canonical after #51. Before the re-vendor, each differing line in every stale region was traced to a past hub commit, so no repository-local rule sat inside a verbatim region. `CODESTYLE.md` carries the hub's skill-pointer form with the repository's own shell rules kept as a subsection of Shell. `.github/copilot-instructions.md` carries the current runbook with an empty Disproved Claims ledger | -| workflow (WORKFLOW.md 5A/5B) | pass | pass | pass | Operational model: `.github/workflows/test-pull-request.yml:9-12` runs on `push` to `develop` (advisory) and `pull_request` into `main` (the enforced gate). `.github/workflows/publish-release.yml:4` is `workflow_dispatch` only, matching `releaseTrigger: dispatch-only`. `.github/workflows/merge-bot-pull-request.yml` uses `actions/create-github-app-token` (SHA-pinned) with the client-id input, matching the baseline mechanism note. `.github/dependabot.yml` declares `github-actions` for both `main` and `develop` | - -csharp, nuget, pypi, python, console, docker, hugo: N/A (no packaging, no application code, no site). - -## Defects (most severe first) - -None. The whole class of stale carried content the pre-promotion `main` run reported (2 `AGENTS.md` and 13 `GOVERNANCE.md` verbatim sections, `.markdownlint-cli2.jsonc`, the carried `configure.sh`, and the README letters) was closed by #40, #43, #50, and #51 and reached `main` with #52. - -## Drift Findings - -- `.editorconfig` and `.gitattributes` carry an intent advisory on both branches. Both are the repository's LF adaptation (registry `lineEndings: lf`), and every hub change since the copies' last commit is CRLF-model or hub-tree specific (Python and Dockerfile pins this tree has no files for). Judged current by meaning, and the advisory will keep firing until the hub's `.editorconfig` stops moving, which is a property of the intent tier rather than of this repository. -- `.github/workflows/publish-release.yml` and `.github/workflows/validate-task.yml` report `hub-only` with an `investigate` disposition in `spec/divergences.json`. Both are the repository's own interface workflows honoring the named contract, and neither can be acted on until the hub settles the fidelity and `appliesTo` call the ledger records as pending. - -## Convergence in Flight - -- ptr727/HomeAutomation-Config#51 (merged into `develop` at `c8252c7`): the resync, one commit per drift class, driven to a Copilot review on its head, one finding fixed and resolved. Coverage read 9 of 11 files on both rounds, which the maintainer accepted at merge. -- ptr727/HomeAutomation-Config#52 (merged as `22ed137`): the `develop` to `main` promotion. Copilot reviewed the head, one finding deferred to ptr727/HomeAutomation-Config#53 (Duplicacy credentials on a command line in `Duplicacy/init-storage.sh`, content from #45 rather than from the resync), thread resolved, checks 6/6, coverage 72 of 76 files, which the maintainer accepted at merge. - -## Proposed Registry / Spec Updates - -- Delete the third `driftNote` ("README self-flags previously-committed secrets"): the README no longer mentions secrets, and #46 and #49 moved every real secret out of the checkout to `~/.secrets/`, so the note describes finished work. Applied in the same change. -- Fill the `operational` config row of `reports/conformance-matrix.md` with this repository as its reference, since it is now the first operational repository with a committed report. Applied in the same change. - -## Escalations - -Raised rather than resolved, per AUDIT.md section 9. Each was already on file from a sibling resync the same day, so this run corroborates rather than opens them. - -1. **The hub's canonical `.github/copilot-instructions.md` names `ptr727/ProjectTemplate` in intent-fidelity prose**, in "A Shape Nothing Recognizes Blocks the Loop and Earns an Issue" ("File an issue on the hub, `ptr727/ProjectTemplate`"). The template-reference scan exempts only verbatim sections and the hub itself, so every downstream carry of the current canonical trips `carried:` on its next audit. #51 reworded the line to name the hub through `AGENTS.md` "Fleet Bootstrap", and PlexCleaner's carry holds the same line. The fix belongs in the canonical wording. Tracked as #720. -2. **`spec/divergences.json` still dispositions `.github/workflows/publish-release.yml` and `.github/workflows/validate-task.yml` as `investigate` with no tracking**, so every repository carrying either reports two `hub-only` findings that no repository can act on. The fidelity and `appliesTo` decision the ledger records as pending is the remedy. Tracked as #669. -3. **The `dead-path` prose check flags a carried mention of the hub-hosted `repo-config/configure.sh`** (`GOVERNANCE.md` "Repository Boundaries and Write Safety" is verbatim carried text) in any repository that has deleted its copy, which is now every converged repository. The mention is a pointer to a hub-hosted file, the shape "Documentation Style Conventions" permits, so the check wants the same hub-hosted exemption the template-reference scan gained. Tracked as #721, where it has already failed a downstream promotion gate. +# Audit: HomeAutomation-Config + +- **Audited branch:** main (`22ed137`), after the promotion of `develop` (`c8252c7`) +- **Types:** source-only (from registry), operational workflow model +- **Verdict:** operational on every mechanized check and every hand-judged dimension +- **Date:** 2026-08-15 +- **Run stamp:** `audit run 2026-08-15T15:19:01Z | hub 704223a`, with the pre-promotion pair `audit run 2026-08-15T14:58:02Z | hub d54862a` (main at `e10a2cf`) and `audit run 2026-08-15T14:58:25Z | hub d54862a | branch override develop` kept for the record + +First committed report for this repository. It was resynced against the hub's `main` (`0e84805`) on this date per `RESYNC.md`, the resync landed on `develop` as ptr727/HomeAutomation-Config#51, and the promotion to `main` merged as ptr727/HomeAutomation-Config#52. The pre-promotion runs read `main` at `e10a2cf` with the whole stale-carried-content class open and `develop` clean, and the post-promotion run at `22ed137` reads what `develop` read, so the two branches now grade the same. + +## Develop Drift + +`develop` vs `main`: identical trees after the #52 promotion merge (15 content commits since #36, conflict-free). The commit-count gap in `main`'s favor is the promotion-merge ancestry artifact and is **benign**. No action. + +## Dimensions + +| Dimension | Letter | Intent | Verdict | Evidence (file:line) | +| --- | --- | --- | --- | --- | +| source-only (`sourceonly.release.tagonly`) | pass | pass | pass | `.github/workflows/publish-release.yml:61-69` inlines `softprops/action-gh-release` (SHA-pinned) with `LICENSE` and `README.md` as the only files, so the release is the tag, GitHub's source archive, README, and LICENSE. No `build-*-task.yml` and no `expect_release_assets` | +| source-only (`sourceonly.nbgv.retained`) | pass | pass | pass | `version.json` retained (hub byte form after #51), and `dotnet/nbgv@master` computes the tag inline at `.github/workflows/publish-release.yml:55`, the same unpinned form the hub's own publisher and catalog snippet use by design | +| branch-model | pass | pass | pass | Both rulesets live and matching the carried payloads by normalized diff (`repo-config/main.json`, `repo-config/operational/develop.json`), confirmed by `repo-config/configure.sh check ptr727/HomeAutomation-Config operational` from this checkout and by the repository's own `AUDIT.md` snippets, all reporting in sync | +| carried-scope | pass | pass | pass | `repo-config/configure.sh` deleted in #40, no `retire`-dispositioned hub path remains. Two `investigate`-dispositioned hits stand (`.github/workflows/publish-release.yml`, `.github/workflows/validate-task.yml`), which is a hub triage rather than a repository finding, see Escalations | +| repo-setup | pass | pass | pass | `spec/secrets.json` carries `baseline` plus `note` only, the shape `docs/repo-config-carry.md` states for a source-only repository whose only publish target maps to no mechanism (the dead `targetMechanisms` map dropped in #51). `configure.sh check` reports the Dependabot security features enabled | +| linter-parity | pass | pass | pass | `.github/workflows/validate-task.yml:30-50` runs markdownlint-cli2, cspell (README and HISTORY scope), actionlint, and editorconfig-checker as pinned action wrappers or Docker, one config per linter at the root. `.github/workflows/test-pull-request.yml:31` carries the ruleset-bound `Check pull request workflow status job` | +| recurring-violations | pass | pass | pass | Hub prose lint over the resync diff is clean. LF throughout, per the registry `lineEndings: lf` and the repository's `.editorconfig`. Three pre-existing whole-tree `dead-path` mentions remain (`GOVERNANCE.md:29`, `GOVERNANCE.md:282`, `OPERATIONS.md:306`), the first two inside carried text and all three left for the files' next edit per the fix-as-edited policy | +| readme-structure | pass | pass | pass | The seven README letters the pre-promotion `main` run reported (license shield placement, `-link` suffixes, `github-link` naming, group membership) were closed in #40 and the post-promotion run reports none | +| agent-instruction-set | pass | pass | pass | Every `AGENTS.md` and `GOVERNANCE.md` verbatim section matches the current canonical after #51. Before the re-vendor, each differing line in every stale region was traced to a past hub commit, so no repository-local rule sat inside a verbatim region. `CODESTYLE.md` carries the hub's skill-pointer form with the repository's own shell rules kept as a subsection of Shell. `.github/copilot-instructions.md` carries the current runbook with an empty Disproved Claims ledger | +| workflow (WORKFLOW.md 5A/5B) | pass | pass | pass | Operational model: `.github/workflows/test-pull-request.yml:9-12` runs on `push` to `develop` (advisory) and `pull_request` into `main` (the enforced gate). `.github/workflows/publish-release.yml:4` is `workflow_dispatch` only, matching `releaseTrigger: dispatch-only`. `.github/workflows/merge-bot-pull-request.yml` uses `actions/create-github-app-token` (SHA-pinned) with the client-id input, matching the baseline mechanism note. `.github/dependabot.yml` declares `github-actions` for both `main` and `develop` | + +csharp, nuget, pypi, python, console, docker, hugo: N/A (no packaging, no application code, no site). + +## Defects (most severe first) + +None. The whole class of stale carried content the pre-promotion `main` run reported (2 `AGENTS.md` and 13 `GOVERNANCE.md` verbatim sections, `.markdownlint-cli2.jsonc`, the carried `configure.sh`, and the README letters) was closed by #40, #43, #50, and #51 and reached `main` with #52. + +## Drift Findings + +- `.editorconfig` and `.gitattributes` carry an intent advisory on both branches. Both are the repository's LF adaptation (registry `lineEndings: lf`), and every hub change since the copies' last commit is CRLF-model or hub-tree specific (Python and Dockerfile pins this tree has no files for). Judged current by meaning, and the advisory will keep firing until the hub's `.editorconfig` stops moving, which is a property of the intent tier rather than of this repository. +- `.github/workflows/publish-release.yml` and `.github/workflows/validate-task.yml` report `hub-only` with an `investigate` disposition in `spec/divergences.json`. Both are the repository's own interface workflows honoring the named contract, and neither can be acted on until the hub settles the fidelity and `appliesTo` call the ledger records as pending. + +## Convergence in Flight + +- ptr727/HomeAutomation-Config#51 (merged into `develop` at `c8252c7`): the resync, one commit per drift class, driven to a Copilot review on its head, one finding fixed and resolved. Coverage read 9 of 11 files on both rounds, which the maintainer accepted at merge. +- ptr727/HomeAutomation-Config#52 (merged as `22ed137`): the `develop` to `main` promotion. Copilot reviewed the head, one finding deferred to ptr727/HomeAutomation-Config#53 (Duplicacy credentials on a command line in `Duplicacy/init-storage.sh`, content from #45 rather than from the resync), thread resolved, checks 6/6, coverage 72 of 76 files, which the maintainer accepted at merge. + +## Proposed Registry / Spec Updates + +- Delete the third `driftNote` ("README self-flags previously-committed secrets"): the README no longer mentions secrets, and #46 and #49 moved every real secret out of the checkout to `~/.secrets/`, so the note describes finished work. Applied in the same change. +- Fill the `operational` config row of `reports/conformance-matrix.md` with this repository as its reference, since it is now the first operational repository with a committed report. Applied in the same change. + +## Escalations + +Raised rather than resolved, per AUDIT.md section 9. Each was already on file from a sibling resync the same day, so this run corroborates rather than opens them. + +1. **The hub's canonical `.github/copilot-instructions.md` names `ptr727/ProjectTemplate` in intent-fidelity prose**, in "A Shape Nothing Recognizes Blocks the Loop and Earns an Issue" ("File an issue on the hub, `ptr727/ProjectTemplate`"). The template-reference scan exempts only verbatim sections and the hub itself, so every downstream carry of the current canonical trips `carried:` on its next audit. #51 reworded the line to name the hub through `AGENTS.md` "Fleet Bootstrap", and PlexCleaner's carry holds the same line. The fix belongs in the canonical wording. Tracked as #720. +2. **`spec/divergences.json` still dispositions `.github/workflows/publish-release.yml` and `.github/workflows/validate-task.yml` as `investigate` with no tracking**, so every repository carrying either reports two `hub-only` findings that no repository can act on. The fidelity and `appliesTo` decision the ledger records as pending is the remedy. Tracked as #669. +3. **The `dead-path` prose check flags a carried mention of the hub-hosted `repo-config/configure.sh`** (`GOVERNANCE.md` "Repository Boundaries and Write Safety" is verbatim carried text) in any repository that has deleted its copy, which is now every converged repository. The mention is a pointer to a hub-hosted file, the shape "Documentation Style Conventions" permits, so the check wants the same hub-hosted exemption the template-reference scan gained. Tracked as #721, where it has already failed a downstream promotion gate. diff --git a/reports/languagetags/audit.md b/reports/languagetags/audit.md index 2ea10d5b..7880c1db 100644 --- a/reports/languagetags/audit.md +++ b/reports/languagetags/audit.md @@ -1,88 +1,88 @@ -# Audit: LanguageTags - -- **Audited branch:** main (`7322d72a5cf975a6c8cc4378165e9df2da095c9a`) -- **Types:** csharp, nuget, codegen (from registry) -- **Verdict:** operational -- **Date:** 2026-07-04 - -## Develop Drift - -`develop` vs `main`: ahead 0, behind 53 (`gh api repos/ptr727/LanguageTags/compare/main...develop` -> `status: behind, ahead_by: 0, behind_by: 53`). **Stale - a drift finding.** `develop` carries none of `main`'s 53 commits. Under the forward-only model (no `main -> develop` back-merge) `develop` must receive `main`-only changes directly (Dependabot bumps merged to `main`, the audited pipeline state); it has not, so `develop` does not reflect the released pipeline. - -## Dimensions - -| Dimension | Letter | Intent | Verdict | Evidence (file:line) | -| --- | --- | --- | --- | --- | -| csharp | pass | pass | pass | `.editorconfig:58` carries the shared `[*.cs]`+ReSharper block (matches the template block); analyzers enforced in `Directory.Build.props:6-9` (`AnalysisLevel=latest-all`, `AnalysisMode=All`, `EnableNETAnalyzers=true`, `TreatWarningsAsErrors=true`); per-project suppressions narrow and documented (`LanguageTags/.editorconfig:7,10` `CA1308`/`CS1591`). See Drift re global EOL default | -| nuget | pass | pass | pass | OIDC login `NuGet/login` `build-release-task.yml:134-136` with an ephemeral key (`steps.nuget-login.outputs.NUGET_API_KEY` at `:145`, not a stored `secrets.NUGET_API_KEY`), no stored API key; `dotnet nuget push --skip-duplicate` `:143-146`, gated `if: inputs.publish && !inputs.smoke` `:140`; `.snupkg` symbols packed/pushed (`LanguageTags.csproj:13,27`) | -| pypi | - | - | N/A | no `pyproject.toml` / `gh-action-pypi-publish` | -| python | - | - | N/A | no Python project | -| console | - | - | N/A | shipped target is the packable library `LanguageTags.csproj:16` (`IsPackable=true`); `LanguageTagsCreate` is the codegen creator (a `System.CommandLine` tool) that ships nothing - no `build-executable-task.yml`, no executable `release-asset` | -| docker | - | - | N/A | no `Dockerfile` / `build-docker-task.yml` | -| codegen | pass | pass | pass | output input-deterministic - generated files carry a static `[GeneratedCode(..., "1.0")]` stamp (`LanguageTags/Iso6392DataGen.cs:6`, `UnM49DataGen.cs:6`), no per-run timestamp/GUID; matrix over `main`+`develop`, each leg checking out its own ref and opening its own PR (`codegen-main` -> `main`, `codegen-develop` -> `develop`) (`run-codegen-pull-request-task.yml:28-33,76-77`), driven daily (`run-periodic-codegen-pull-request.yml:8-9`); App secrets present | -| branch-model | drift | pass | drift | both branches protected (`develop`/`main` rulesets `enforcement: active`); live rulesets match committed `repo-config/ruleset-{develop,main}.json` **except** `bypass_actors` (live carries `RepositoryRole 5 always`, committed omits) and `required_reviewers: []` (live present, committed omits). The required-check `context` `Check pull request workflow status job` matches both the committed rulesets and the repo's own aggregator `name:` (`test-pull-request.yml:45`) - the new fleet canonical, not a drift. Off-baseline filenames - see Drift | -| repo-setup | pass | pass | pass | actions + dependabot stores both hold `CODEGEN_APP_CLIENT_ID`, `CODEGEN_APP_PRIVATE_KEY` (codegen App + merge-bot) and `NUGET_USERNAME` (the OIDC `NuGet/login` profile name `build-release-task.yml:136`); no forbidden `NUGET_API_KEY` (nuget-oidc) or `CODEGEN_APP_ID` (codegen-app) | -| linter-parity | pass | pass | pass | one `.markdownlint-cli2.jsonc` + `cspell.json` drive editor + CLI + CI (`validate-task.yml:56-69`); csharpier/editorconfig shared via `.config/dotnet-tools.json`; CI runs each linter (csharpier check `:47-51`, `dotnet format style` `:53-54`, markdownlint `:56-59`, cspell `:63-69`, actionlint `:71-72`) | -| recurring-violations | drift | pass | drift | docs ASCII-clean (em-dash/smart-quote grep of README/HISTORY/AGENTS/CODESTYLE/WORKFLOW/copilot-instructions -> none); `cspell.json:3` sets `"language": "en-US"`; real endings compliant (`.gitattributes:6,12-19` `* -text` + LF pins for `*.sh`/Dockerfiles/`.husky/pre-commit`). **Letter miss:** `.editorconfig` lacks the global `[*] end_of_line = crlf` default - see Drift | -| readme-structure | drift | pass | drift | Build and Distribution (Build Status/Releases/Release Notes), Getting Started, Table of Contents, Use Cases, Usage, Installation, Questions or Issues, 3rd Party Tools, License all present, but `## Installation` `README.md:373` sits **after** `## Usage` `:112` (spec order is Installation before Usage), `## Contributing` `:437` fills the Development-Environment-Setup slot under a different name, extra `## Build Artifacts`/`## Tag Theory` interleaved; minor: missing colon `:9`, shields header `<!-- Shields links -->` vs canonical `<!-- Shields -->` `:575` | -| workflow (WORKFLOW.md 5A/5B) | drift | pass | drift | all applicable D-guarantees hold by **outcome**; divergences are structural (see 5A/5B): continuous per-branch push-publish instead of two-phase scheduled matrix, no `changes` paths-filter, keys off `github.ref_name` without `IGNORE_GITHUB_REF`, plain `release-asset` name + exact-name download, dispatch guard skips instead of failing fast | - -## WORKFLOW.md 5A Static Audit (applicable D-guarantees) - -- **D1.1 / D1.4 (paths-filter):** DRIFT - no `changes` / `dorny/paths-filter` job; `validate` and `smoke-build` run on **every** push including docs-only and `.github/workflows/**`-only (`test-pull-request.yml:25-40`, deliberate per header `:3-11`). Over-builds; the "changed target slips unbuilt" failure cannot occur with a single always-built target. -- **D1.2 (validation always runs):** PASS - `validate` runs unconditionally `if: !github.event.deleted` (`test-pull-request.yml:25-28`); the aggregator `needs:` it `:47`. -- **D1.3 (smoke uploads/pushes nothing):** PASS - the smoke caller passes `publish: false` (`test-pull-request.yml:38-40`); every push/upload step is gated `if: inputs.publish && !inputs.smoke` (`build-release-task.yml:132,140,150,163,172`). -- **D1.5 (one aggregator):** PASS - `check-workflow-status`, `needs: [validate, smoke-build]`, `always() && !github.event.deleted`, fails on any non-`success` (`test-pull-request.yml:44-60`). Name is the ruleset-bound context `:45`. -- **D2.1 / D2.2 (validate-at-entry):** PASS - `validate-release` asserts branch<->classification **both** directions, strips `+buildmetadata`, skips on smoke (`build-release-task.yml:65-94`); `build` and `github-release` both `needs:` it `:98,173`. -- **D2.3 (dispatch guard):** DRIFT - the publish job `if: github.event_name == 'push' || github.ref_name == 'main' || github.ref_name == 'develop'` (`publish-release.yml:48`) silently **skips** a dispatch from any other ref rather than failing fast with `::error::`. Skip is safe but non-canonical. -- **D3.1 (version from checked-out branch):** DRIFT (letter) / PASS (intent) - `get-version` uses the default checkout and NBGV classifies from `GITHUB_REF`; **no** `IGNORE_GITHUB_REF=true` (`build-release-task.yml:50-61`). Correct here because the publisher builds only the triggering branch per run (`github.ref` aligned); a future both-branch matrix would misclassify. -- **D3.2 (default = public, others = prerelease):** PASS - `version.json:4` `publicReleaseRefSpec ^refs/heads/main$`; configuration `github.ref_name == 'main' && 'Release' || 'Debug'` `:120`; `prerelease: github.ref_name != 'main'` `:213`. All name `main`. -- **D3.3 (floor + git height):** PASS - `version.json:3` `version "1.5"`; NBGV appends the git height. -- **D3.4 (registry version per classification):** PASS - build injects `-property:PackageVersion=SemVer2` `build-release-task.yml:123,127`; NuGet.org derives prerelease from the `-g<sha>` suffix. -- **D4.1 (two-phase / both branches):** DRIFT - the publisher `push`-triggers on every push to `main`/`develop` touching shipped paths (`publish-release.yml:3-11`), with **no** weekly schedule and **no** both-branch matrix - WORKFLOW.md's opt-in continuous-release mode used as the default (no opt-in repository variable). Outcomes correct (main -> stable, develop -> prerelease); the divergence is structural. -- **D4.2 / D4.3 / D4.4 (release):** PASS - `target_commitish` = `GitCommitId` `:212`, `prerelease = github.ref_name != 'main'` `:213`, create gated `exists == false || workflow_dispatch` `:207`, `fail_on_unmatched_files: true` `:214`, contents tag + `LICENSE` + `README.md` + `LanguageTags.7z` `:215-218`; re-push no-op via `--skip-duplicate` `:146`. -- **D5 (cleanup):** PASS - asset delete at the consumer under the **same** gate as create `:224`, `continue-on-error: true` `:225`, filtered by `select(.name == "release-asset")` (not a blanket `.artifacts[].id`) `:230-238`; the one upload sets `retention-days: 1` `:168`. -- **D6.1 / D6.3 (seam):** DRIFT - single asset named `release-asset` (not `release-asset-<branch>-<target>`) `:166`, downloaded by exact `name:` (not `pattern:` / `merge-multiple:`) `:187`. D6.2: branch-derived config keys off `github.ref_name` (no `inputs.branch`) - acceptable for a single-branch-per-run publisher, non-canonical. -- **D7.1 / D7.2 (concurrency / permissions):** PASS - publisher group `${{ github.workflow }}` ref-independent + `cancel-in-progress: false` (`publish-release.yml:29-31`); the reusable task declares no `permissions:`, the publish caller grants `contents` / `id-token` / `actions: write` at the one entry point `:51-54`, and the smoke caller grants only `contents: read` (`test-pull-request.yml:36-37`). -- **D8.1 (merge-bot):** PASS - auto-merge on `opened`/`reopened`, method by base ref (`develop => --squash`, `main => --merge`) `merge-bot-pull-request.yml:51-56`; codegen head/base pairing pinned `:66-73`; disable on maintainer `synchronize` `:105-112`; concurrency keyed on the PR number `:20-22`. -- **D8.2 (codegen + Dependabot):** PASS - codegen runs as a `main`/`develop` matrix, deterministic from upstream registries, each leg its own PR (`run-codegen-pull-request-task.yml:28-33,70-81`), driven daily; Dependabot dual-targets `main`+`develop` for `nuget` and `github-actions` (`.github/dependabot.yml`), security PRs to the default branch. -- **D9.1 (SHA-pinning):** PASS - all actions SHA-pinned with version comments; `dotnet/nbgv@master` is the sanctioned lagging-tag exception (`build-release-task.yml:57-61`). -- **Console / PyPI / Docker / wrapper 5A addenda:** N/A. - -## WORKFLOW.md 5B Trace (applicable scenarios) - -- **S1** (PR touches the library): `validate` + `smoke-build` run, no push/upload (`publish: false`), `validate-release` skipped (smoke), aggregator success, version prerelease. PASS. -- **S2 / S3** (docs-only / workflow-only PR): `smoke-build` **runs** (no paths-filter) instead of skipping; aggregator success - safe but over-builds. DRIFT. -- **S4** (PR base = main): smoke prerelease, `validate-release` skipped (smoke), promotion not blocked. PASS. -- **S5** (push not touching shipped paths): publish-release not triggered (`on.push.paths` inclusion list). PASS. -- **S6** (push to develop touching shipped paths): publishes a develop **prerelease** by default, not opt-in. DRIFT (continuous release). -- **S7** (both-branch matrix publish): N/A - no schedule/matrix; a dispatch publishes only its triggering branch. -- **S8** (dispatch from a non-publishable ref): publish job **skipped** (template fails fast). DRIFT. -- **S9** (re-run, version unchanged): release-create + asset-delete skipped, NuGet `--skip-duplicate` server no-op. PASS. -- **S10** (branch/version disagree): `validate-release` fails loud. PASS. -- **S11** (wrapper bump): N/A. -- **Codegen (daily):** the task regenerates `LanguageData`/`*DataGen.cs` deterministically, opens `codegen-main`/`codegen-develop` PRs only on change, the merge-bot auto-merges each, and the shipped data change triggers the publisher. PASS. - -## Defects (most severe first) - -None. No applicable check fails both letter and intent. - -## Drift Findings - -1. **Release model - continuous per-branch push-publish, not two-phase.** `publish-release.yml:3-11,48` publishes on every push to `main`/`develop` touching a shipped path, no weekly schedule, no both-branch matrix - WORKFLOW.md D4.1's opt-in continuous mode as the default. Outcomes correct (main -> stable, develop -> prerelease); structural divergence. **(Recurs with Utilities.)** -2. **Version classification keys off `github.ref_name` without `IGNORE_GITHUB_REF`.** `build-release-task.yml:50-61` runs NBGV on the default checkout; config/prerelease read `github.ref_name` `:120,213`. Correct only because the publisher builds one branch per run with `github.ref` aligned; a future both-branch matrix would misclassify. **(Recurs with Utilities.)** -3. **Seam handoff not canonical.** Single artifact `release-asset` with exact-name download instead of `release-asset-<branch>-<target>` + `pattern:` / `merge-multiple:`; forks the verbatim `github-release` carry and will not extend to a second target without rework. `build-release-task.yml:166,187`. **(Recurs with Utilities.)** -4. **No `changes` paths-filter; every push smoke-builds.** `test-pull-request.yml:25-40` runs `validate` + `smoke-build` on every push including docs-only and workflow-only. **(Recurs with Utilities / PlexCleaner.)** -5. **`.editorconfig` lacks the canonical global line-ending default.** The `[*]` block (`.editorconfig:17-22`) sets `charset`/indent but **no** `end_of_line`; CRLF is re-declared per file-type (`:26,31,36,41`) and on `[*.cs]` `:59` - the older per-extension form the strengthened `recurring.eol` flags. The template's own `.editorconfig:22` carries `[*] end_of_line = crlf`. `.gitattributes:6,12-19` still enforces endings, so real files are compliant. **(Recurs with Utilities / PlexCleaner - now three repos.)** -6. **Dispatch guard skips instead of failing fast.** `publish-release.yml:48` silently no-ops a `workflow_dispatch` from a non-`main`/`develop` ref; WORKFLOW.md D2.3 wants a fail-fast `::error::`. **(Recurs with Utilities / PlexCleaner.)** -7. **Committed rulesets omit fields the live rulesets carry.** `repo-config/ruleset-{develop,main}.json` omit `bypass_actors` (live carries `RepositoryRole 5 always`) and `required_reviewers: []`. The required-check `context` matches, so enforcement is intact. **(Recurs with Utilities.)** -8. **repo-config filenames off-baseline.** Committed as `repo-config/ruleset-{develop,main}.json`; `spec/files.json` expects `repo-config/develop.json` / `main.json`. **(Recurs with Utilities / PlexCleaner.)** -9. **README section order / naming.** `## Installation` `README.md:373` sits after `## Usage` `:112`; `## Contributing` `:437` fills the Development-Environment-Setup slot under a different name; extra `## Build Artifacts`/`## Tag Theory` interleaved; missing colon `:9`; shields header `<!-- Shields links -->` vs canonical `<!-- Shields -->` `:575`. -10. **Stale hardcoded versions in the csproj.** `LanguageTags.csproj:4,10,14,24,28` hardcode `AssemblyVersion`/`FileVersion`/`Version` `1.0.0.0` and `InformationalVersion`/`PackageVersion` `1.0.0-pre`, vs `version.json:3` floor `1.5`. CI overrides via `-property:*=SemVer2` (`build-release-task.yml:123-127`), so published packages are correct, but a local `dotnet pack` produces a wrong `1.0.0` package. **(Recurs with Utilities.)** -11. **`develop` stale** (behind 53, ahead 0). See Develop Drift. - -## Proposed Registry / Spec Updates - -- Registry `LanguageTags` types `["csharp","nuget","codegen"]`, publish `nuget` via `oidc`, status `cataloged` are accurate; no change. Consider advancing `status` once the structural workflow drifts are addressed. -- **Spec gap - `NUGET_USERNAME` under nuget-oidc.** `spec/secrets.json` models `nuget-oidc` as `requires: []`, but the OIDC `NuGet/login` needs `NUGET_USERNAME` (the nuget.org profile name, `build-release-task.yml:136`). Model `NUGET_USERNAME` as a required secret (stored `actions`, and `dependabot` since a bump republishes) so repo-setup does not read it as orphaned. -- **Spec signal (recurring across repos):** the missing global `[*] end_of_line = crlf` default (now caught by `recurring.eol`), the plain `release-asset` (non-`<branch>-<target>`, non-`pattern:`) seam, the single-branch continuous-publish shape, the skip-not-fail dispatch guard, and the off-baseline `repo-config/ruleset-*.json` filenames now recur across Utilities, PlexCleaner, and LanguageTags. Candidates for machine checks or a baseline-filename alias in `spec/files.json`, rather than per-repo notes. +# Audit: LanguageTags + +- **Audited branch:** main (`7322d72a5cf975a6c8cc4378165e9df2da095c9a`) +- **Types:** csharp, nuget, codegen (from registry) +- **Verdict:** operational +- **Date:** 2026-07-04 + +## Develop Drift + +`develop` vs `main`: ahead 0, behind 53 (`gh api repos/ptr727/LanguageTags/compare/main...develop` -> `status: behind, ahead_by: 0, behind_by: 53`). **Stale - a drift finding.** `develop` carries none of `main`'s 53 commits. Under the forward-only model (no `main -> develop` back-merge) `develop` must receive `main`-only changes directly (Dependabot bumps merged to `main`, the audited pipeline state); it has not, so `develop` does not reflect the released pipeline. + +## Dimensions + +| Dimension | Letter | Intent | Verdict | Evidence (file:line) | +| --- | --- | --- | --- | --- | +| csharp | pass | pass | pass | `.editorconfig:58` carries the shared `[*.cs]`+ReSharper block (matches the template block); analyzers enforced in `Directory.Build.props:6-9` (`AnalysisLevel=latest-all`, `AnalysisMode=All`, `EnableNETAnalyzers=true`, `TreatWarningsAsErrors=true`); per-project suppressions narrow and documented (`LanguageTags/.editorconfig:7,10` `CA1308`/`CS1591`). See Drift re global EOL default | +| nuget | pass | pass | pass | OIDC login `NuGet/login` `build-release-task.yml:134-136` with an ephemeral key (`steps.nuget-login.outputs.NUGET_API_KEY` at `:145`, not a stored `secrets.NUGET_API_KEY`), no stored API key; `dotnet nuget push --skip-duplicate` `:143-146`, gated `if: inputs.publish && !inputs.smoke` `:140`; `.snupkg` symbols packed/pushed (`LanguageTags.csproj:13,27`) | +| pypi | - | - | N/A | no `pyproject.toml` / `gh-action-pypi-publish` | +| python | - | - | N/A | no Python project | +| console | - | - | N/A | shipped target is the packable library `LanguageTags.csproj:16` (`IsPackable=true`); `LanguageTagsCreate` is the codegen creator (a `System.CommandLine` tool) that ships nothing - no `build-executable-task.yml`, no executable `release-asset` | +| docker | - | - | N/A | no `Dockerfile` / `build-docker-task.yml` | +| codegen | pass | pass | pass | output input-deterministic - generated files carry a static `[GeneratedCode(..., "1.0")]` stamp (`LanguageTags/Iso6392DataGen.cs:6`, `UnM49DataGen.cs:6`), no per-run timestamp/GUID; matrix over `main`+`develop`, each leg checking out its own ref and opening its own PR (`codegen-main` -> `main`, `codegen-develop` -> `develop`) (`run-codegen-pull-request-task.yml:28-33,76-77`), driven daily (`run-periodic-codegen-pull-request.yml:8-9`); App secrets present | +| branch-model | drift | pass | drift | both branches protected (`develop`/`main` rulesets `enforcement: active`); live rulesets match committed `repo-config/ruleset-{develop,main}.json` **except** `bypass_actors` (live carries `RepositoryRole 5 always`, committed omits) and `required_reviewers: []` (live present, committed omits). The required-check `context` `Check pull request workflow status job` matches both the committed rulesets and the repo's own aggregator `name:` (`test-pull-request.yml:45`) - the new fleet canonical, not a drift. Off-baseline filenames - see Drift | +| repo-setup | pass | pass | pass | actions + dependabot stores both hold `CODEGEN_APP_CLIENT_ID`, `CODEGEN_APP_PRIVATE_KEY` (codegen App + merge-bot) and `NUGET_USERNAME` (the OIDC `NuGet/login` profile name `build-release-task.yml:136`); no forbidden `NUGET_API_KEY` (nuget-oidc) or `CODEGEN_APP_ID` (codegen-app) | +| linter-parity | pass | pass | pass | one `.markdownlint-cli2.jsonc` + `cspell.json` drive editor + CLI + CI (`validate-task.yml:56-69`); csharpier/editorconfig shared via `.config/dotnet-tools.json`; CI runs each linter (csharpier check `:47-51`, `dotnet format style` `:53-54`, markdownlint `:56-59`, cspell `:63-69`, actionlint `:71-72`) | +| recurring-violations | drift | pass | drift | docs ASCII-clean (em-dash/smart-quote grep of README/HISTORY/AGENTS/CODESTYLE/WORKFLOW/copilot-instructions -> none); `cspell.json:3` sets `"language": "en-US"`; real endings compliant (`.gitattributes:6,12-19` `* -text` + LF pins for `*.sh`/Dockerfiles/`.husky/pre-commit`). **Letter miss:** `.editorconfig` lacks the global `[*] end_of_line = crlf` default - see Drift | +| readme-structure | drift | pass | drift | Build and Distribution (Build Status/Releases/Release Notes), Getting Started, Table of Contents, Use Cases, Usage, Installation, Questions or Issues, 3rd Party Tools, License all present, but `## Installation` `README.md:373` sits **after** `## Usage` `:112` (spec order is Installation before Usage), `## Contributing` `:437` fills the Development-Environment-Setup slot under a different name, extra `## Build Artifacts`/`## Tag Theory` interleaved; minor: missing colon `:9`, shields header `<!-- Shields links -->` vs canonical `<!-- Shields -->` `:575` | +| workflow (WORKFLOW.md 5A/5B) | drift | pass | drift | all applicable D-guarantees hold by **outcome**; divergences are structural (see 5A/5B): continuous per-branch push-publish instead of two-phase scheduled matrix, no `changes` paths-filter, keys off `github.ref_name` without `IGNORE_GITHUB_REF`, plain `release-asset` name + exact-name download, dispatch guard skips instead of failing fast | + +## WORKFLOW.md 5A Static Audit (applicable D-guarantees) + +- **D1.1 / D1.4 (paths-filter):** DRIFT - no `changes` / `dorny/paths-filter` job; `validate` and `smoke-build` run on **every** push including docs-only and `.github/workflows/**`-only (`test-pull-request.yml:25-40`, deliberate per header `:3-11`). Over-builds; the "changed target slips unbuilt" failure cannot occur with a single always-built target. +- **D1.2 (validation always runs):** PASS - `validate` runs unconditionally `if: !github.event.deleted` (`test-pull-request.yml:25-28`); the aggregator `needs:` it `:47`. +- **D1.3 (smoke uploads/pushes nothing):** PASS - the smoke caller passes `publish: false` (`test-pull-request.yml:38-40`); every push/upload step is gated `if: inputs.publish && !inputs.smoke` (`build-release-task.yml:132,140,150,163,172`). +- **D1.5 (one aggregator):** PASS - `check-workflow-status`, `needs: [validate, smoke-build]`, `always() && !github.event.deleted`, fails on any non-`success` (`test-pull-request.yml:44-60`). Name is the ruleset-bound context `:45`. +- **D2.1 / D2.2 (validate-at-entry):** PASS - `validate-release` asserts branch<->classification **both** directions, strips `+buildmetadata`, skips on smoke (`build-release-task.yml:65-94`); `build` and `github-release` both `needs:` it `:98,173`. +- **D2.3 (dispatch guard):** DRIFT - the publish job `if: github.event_name == 'push' || github.ref_name == 'main' || github.ref_name == 'develop'` (`publish-release.yml:48`) silently **skips** a dispatch from any other ref rather than failing fast with `::error::`. Skip is safe but non-canonical. +- **D3.1 (version from checked-out branch):** DRIFT (letter) / PASS (intent) - `get-version` uses the default checkout and NBGV classifies from `GITHUB_REF`; **no** `IGNORE_GITHUB_REF=true` (`build-release-task.yml:50-61`). Correct here because the publisher builds only the triggering branch per run (`github.ref` aligned); a future both-branch matrix would misclassify. +- **D3.2 (default = public, others = prerelease):** PASS - `version.json:4` `publicReleaseRefSpec ^refs/heads/main$`; configuration `github.ref_name == 'main' && 'Release' || 'Debug'` `:120`; `prerelease: github.ref_name != 'main'` `:213`. All name `main`. +- **D3.3 (floor + git height):** PASS - `version.json:3` `version "1.5"`; NBGV appends the git height. +- **D3.4 (registry version per classification):** PASS - build injects `-property:PackageVersion=SemVer2` `build-release-task.yml:123,127`; NuGet.org derives prerelease from the `-g<sha>` suffix. +- **D4.1 (two-phase / both branches):** DRIFT - the publisher `push`-triggers on every push to `main`/`develop` touching shipped paths (`publish-release.yml:3-11`), with **no** weekly schedule and **no** both-branch matrix - WORKFLOW.md's opt-in continuous-release mode used as the default (no opt-in repository variable). Outcomes correct (main -> stable, develop -> prerelease); the divergence is structural. +- **D4.2 / D4.3 / D4.4 (release):** PASS - `target_commitish` = `GitCommitId` `:212`, `prerelease = github.ref_name != 'main'` `:213`, create gated `exists == false || workflow_dispatch` `:207`, `fail_on_unmatched_files: true` `:214`, contents tag + `LICENSE` + `README.md` + `LanguageTags.7z` `:215-218`; re-push no-op via `--skip-duplicate` `:146`. +- **D5 (cleanup):** PASS - asset delete at the consumer under the **same** gate as create `:224`, `continue-on-error: true` `:225`, filtered by `select(.name == "release-asset")` (not a blanket `.artifacts[].id`) `:230-238`; the one upload sets `retention-days: 1` `:168`. +- **D6.1 / D6.3 (seam):** DRIFT - single asset named `release-asset` (not `release-asset-<branch>-<target>`) `:166`, downloaded by exact `name:` (not `pattern:` / `merge-multiple:`) `:187`. D6.2: branch-derived config keys off `github.ref_name` (no `inputs.branch`) - acceptable for a single-branch-per-run publisher, non-canonical. +- **D7.1 / D7.2 (concurrency / permissions):** PASS - publisher group `${{ github.workflow }}` ref-independent + `cancel-in-progress: false` (`publish-release.yml:29-31`); the reusable task declares no `permissions:`, the publish caller grants `contents` / `id-token` / `actions: write` at the one entry point `:51-54`, and the smoke caller grants only `contents: read` (`test-pull-request.yml:36-37`). +- **D8.1 (merge-bot):** PASS - auto-merge on `opened`/`reopened`, method by base ref (`develop => --squash`, `main => --merge`) `merge-bot-pull-request.yml:51-56`; codegen head/base pairing pinned `:66-73`; disable on maintainer `synchronize` `:105-112`; concurrency keyed on the PR number `:20-22`. +- **D8.2 (codegen + Dependabot):** PASS - codegen runs as a `main`/`develop` matrix, deterministic from upstream registries, each leg its own PR (`run-codegen-pull-request-task.yml:28-33,70-81`), driven daily; Dependabot dual-targets `main`+`develop` for `nuget` and `github-actions` (`.github/dependabot.yml`), security PRs to the default branch. +- **D9.1 (SHA-pinning):** PASS - all actions SHA-pinned with version comments; `dotnet/nbgv@master` is the sanctioned lagging-tag exception (`build-release-task.yml:57-61`). +- **Console / PyPI / Docker / wrapper 5A addenda:** N/A. + +## WORKFLOW.md 5B Trace (applicable scenarios) + +- **S1** (PR touches the library): `validate` + `smoke-build` run, no push/upload (`publish: false`), `validate-release` skipped (smoke), aggregator success, version prerelease. PASS. +- **S2 / S3** (docs-only / workflow-only PR): `smoke-build` **runs** (no paths-filter) instead of skipping; aggregator success - safe but over-builds. DRIFT. +- **S4** (PR base = main): smoke prerelease, `validate-release` skipped (smoke), promotion not blocked. PASS. +- **S5** (push not touching shipped paths): publish-release not triggered (`on.push.paths` inclusion list). PASS. +- **S6** (push to develop touching shipped paths): publishes a develop **prerelease** by default, not opt-in. DRIFT (continuous release). +- **S7** (both-branch matrix publish): N/A - no schedule/matrix; a dispatch publishes only its triggering branch. +- **S8** (dispatch from a non-publishable ref): publish job **skipped** (template fails fast). DRIFT. +- **S9** (re-run, version unchanged): release-create + asset-delete skipped, NuGet `--skip-duplicate` server no-op. PASS. +- **S10** (branch/version disagree): `validate-release` fails loud. PASS. +- **S11** (wrapper bump): N/A. +- **Codegen (daily):** the task regenerates `LanguageData`/`*DataGen.cs` deterministically, opens `codegen-main`/`codegen-develop` PRs only on change, the merge-bot auto-merges each, and the shipped data change triggers the publisher. PASS. + +## Defects (most severe first) + +None. No applicable check fails both letter and intent. + +## Drift Findings + +1. **Release model - continuous per-branch push-publish, not two-phase.** `publish-release.yml:3-11,48` publishes on every push to `main`/`develop` touching a shipped path, no weekly schedule, no both-branch matrix - WORKFLOW.md D4.1's opt-in continuous mode as the default. Outcomes correct (main -> stable, develop -> prerelease); structural divergence. **(Recurs with Utilities.)** +2. **Version classification keys off `github.ref_name` without `IGNORE_GITHUB_REF`.** `build-release-task.yml:50-61` runs NBGV on the default checkout; config/prerelease read `github.ref_name` `:120,213`. Correct only because the publisher builds one branch per run with `github.ref` aligned; a future both-branch matrix would misclassify. **(Recurs with Utilities.)** +3. **Seam handoff not canonical.** Single artifact `release-asset` with exact-name download instead of `release-asset-<branch>-<target>` + `pattern:` / `merge-multiple:`; forks the verbatim `github-release` carry and will not extend to a second target without rework. `build-release-task.yml:166,187`. **(Recurs with Utilities.)** +4. **No `changes` paths-filter; every push smoke-builds.** `test-pull-request.yml:25-40` runs `validate` + `smoke-build` on every push including docs-only and workflow-only. **(Recurs with Utilities / PlexCleaner.)** +5. **`.editorconfig` lacks the canonical global line-ending default.** The `[*]` block (`.editorconfig:17-22`) sets `charset`/indent but **no** `end_of_line`; CRLF is re-declared per file-type (`:26,31,36,41`) and on `[*.cs]` `:59` - the older per-extension form the strengthened `recurring.eol` flags. The template's own `.editorconfig:22` carries `[*] end_of_line = crlf`. `.gitattributes:6,12-19` still enforces endings, so real files are compliant. **(Recurs with Utilities / PlexCleaner - now three repos.)** +6. **Dispatch guard skips instead of failing fast.** `publish-release.yml:48` silently no-ops a `workflow_dispatch` from a non-`main`/`develop` ref; WORKFLOW.md D2.3 wants a fail-fast `::error::`. **(Recurs with Utilities / PlexCleaner.)** +7. **Committed rulesets omit fields the live rulesets carry.** `repo-config/ruleset-{develop,main}.json` omit `bypass_actors` (live carries `RepositoryRole 5 always`) and `required_reviewers: []`. The required-check `context` matches, so enforcement is intact. **(Recurs with Utilities.)** +8. **repo-config filenames off-baseline.** Committed as `repo-config/ruleset-{develop,main}.json`; `spec/files.json` expects `repo-config/develop.json` / `main.json`. **(Recurs with Utilities / PlexCleaner.)** +9. **README section order / naming.** `## Installation` `README.md:373` sits after `## Usage` `:112`; `## Contributing` `:437` fills the Development-Environment-Setup slot under a different name; extra `## Build Artifacts`/`## Tag Theory` interleaved; missing colon `:9`; shields header `<!-- Shields links -->` vs canonical `<!-- Shields -->` `:575`. +10. **Stale hardcoded versions in the csproj.** `LanguageTags.csproj:4,10,14,24,28` hardcode `AssemblyVersion`/`FileVersion`/`Version` `1.0.0.0` and `InformationalVersion`/`PackageVersion` `1.0.0-pre`, vs `version.json:3` floor `1.5`. CI overrides via `-property:*=SemVer2` (`build-release-task.yml:123-127`), so published packages are correct, but a local `dotnet pack` produces a wrong `1.0.0` package. **(Recurs with Utilities.)** +11. **`develop` stale** (behind 53, ahead 0). See Develop Drift. + +## Proposed Registry / Spec Updates + +- Registry `LanguageTags` types `["csharp","nuget","codegen"]`, publish `nuget` via `oidc`, status `cataloged` are accurate; no change. Consider advancing `status` once the structural workflow drifts are addressed. +- **Spec gap - `NUGET_USERNAME` under nuget-oidc.** `spec/secrets.json` models `nuget-oidc` as `requires: []`, but the OIDC `NuGet/login` needs `NUGET_USERNAME` (the nuget.org profile name, `build-release-task.yml:136`). Model `NUGET_USERNAME` as a required secret (stored `actions`, and `dependabot` since a bump republishes) so repo-setup does not read it as orphaned. +- **Spec signal (recurring across repos):** the missing global `[*] end_of_line = crlf` default (now caught by `recurring.eol`), the plain `release-asset` (non-`<branch>-<target>`, non-`pattern:`) seam, the single-branch continuous-publish shape, the skip-not-fail dispatch guard, and the off-baseline `repo-config/ruleset-*.json` filenames now recur across Utilities, PlexCleaner, and LanguageTags. Candidates for machine checks or a baseline-filename alias in `spec/files.json`, rather than per-repo notes. diff --git a/reports/photocleaner/audit.md b/reports/photocleaner/audit.md index a9c00a61..8762d609 100644 --- a/reports/photocleaner/audit.md +++ b/reports/photocleaner/audit.md @@ -1,57 +1,57 @@ -# Audit: PhotoCleaner - -- **Audited branch:** main (`f56178a`) -- **Types:** csharp, console, docker (from registry) -- **Verdict:** operational -- **Date:** 2026-08-15 -- **Run stamp:** `audit run 2026-08-15T18:43:20Z | hub b09078e` - -Supersedes the earlier 2026-08-15 snapshot, which measured `main` before the promotion. The resyncs against the hub (ptr727/PhotoCleaner#49, ptr727/PhotoCleaner#50, ptr727/PhotoCleaner#52) are promoted to `main` by ptr727/PhotoCleaner#51, and the deterministic audit reports **clean** at `main` and at `develop` with the hub at `b09078e`. The two hub-only workflow findings the earlier snapshot carried are gone as well, since the hub now declares the publisher and validator workflows at intent fidelity (#736). - -## Develop Drift - -`develop` vs `main`: identical content, since ptr727/PhotoCleaner#51 merged `develop` at `ab23034` and nothing has landed since. The audit at `develop` reports zero findings. - -The promotion was blocked for one round by the prose gate's `dead-path` rule flagging verbatim mentions of the retired `repo-config/configure.sh`, filed as #721 and fixed by #731. The gate fetches its rules from hub `develop` on a `develop`-targeted run, so a re-run cleared it with no repo change. Copilot read 18 of 19 changed files on the promotion in both rounds and raised no threads. Its one suppressed finding, a Mermaid edge in the hub-carried `WORKFLOW.md` diagram, was answered on the pull request as declined, since the diagram is hub content and the D1 text beside it already states the rule. - -## Dimensions - -Judged at `main`. Type-dimension checks are hand-judged per AUDIT.md section 4 and are unchanged from the 2026-08-01 snapshot, since ptr727/PhotoCleaner#49, ptr727/PhotoCleaner#50, and ptr727/PhotoCleaner#52 touched governance and configuration only. - -| Dimension | Letter | Intent | Verdict | Evidence (file:line) | -| --- | --- | --- | --- | --- | -| csharp | pass | pass | pass | `Directory.Build.props` carries the analyzer set and `TreatWarningsAsErrors`, `Directory.Packages.props` centralizes versions, `.editorconfig` carries the shared `[*.cs]` block | -| console | pass | pass | pass | System.CommandLine console (`PhotoCleaner/PhotoCleaner.csproj`, `OutputType=Exe`, net10.0), `build-executable-task.yml` aggregates per-runtime output to `release-asset-<branch>-*`, gated `!smoke` | -| docker | pass | pass | pass | `Docker/Dockerfile` multi-arch, `Docker/README.md` present, `build-docker-task.yml` uses a registry layer cache (`buildcache-<branch>`), the image re-pushes on publish | -| branch-model | pass | pass | pass | `repo-config/configure.sh check ptr727/PhotoCleaner release` at hub `0e84805`: both rulesets and every general setting match, nothing to apply | -| repo-setup | pass | pass | pass | Every name in `spec/secrets.json` present in both the Actions and Dependabot stores (`CODECOV_TOKEN` included), no forbidden name | -| linter-parity | pass | pass | pass | `validate-task.yml` runs csharpier check, `dotnet format style --verify-no-changes`, markdownlint, cspell, actionlint, editorconfig-checker, and the hub prose gate. One config per linter | -| recurring-violations | pass | pass | pass | `prose_lint.py --diff origin/develop` clean on ptr727/PhotoCleaner#50, `repo_gate.py` clean on `develop` (the `eol-coverage` forward-declaration gap closed in ptr727/PhotoCleaner#50) | -| readme-structure | pass | pass | pass | Required sections present in order, reference names and link groups per `spec/readme-structure.md`, all mechanical README checks clean at `main` | -| workflow (WORKFLOW.md 5A/5B) | pass | pass | pass | `build-release-task.yml` carries `validate-release` and the re-vendored `github-release` job (interface contract satisfied by name), and `publish-release.yml` and `validate-task.yml` are declared intent carries as of hub #736 | -| agent-instruction-set | pass | pass | pass | Every verbatim `AGENTS.md`/`GOVERNANCE.md` section byte-matches the hub canonical, and every intent carry (`CODESTYLE.md`, `WORKFLOW.md`, `AUDIT.md`, `.github/copilot-instructions.md`, the linter configs) is reconciled against the hub's history in ptr727/PhotoCleaner#50 and ptr727/PhotoCleaner#52 with the repo's own adaptations kept | - -nuget, pypi, python: N/A (no packaging, no Python). - -## Defects (most severe first) - -None. - -## Drift Findings - -None. The deterministic audit is clean at `main` and `develop`, and no hand-judged dimension reports a letter miss. - -## Convergence in Flight - -None. ptr727/PhotoCleaner#50, ptr727/PhotoCleaner#52 (to `develop`) and ptr727/PhotoCleaner#51 (`develop -> main`) are merged. - -## Proposed Registry / Spec Updates - -- The `driftNotes` are unchanged. The first describes the publish shape (multi-arch Docker plus a github-release 7z, two-phase release), which still holds. The second records the private-for-now decision, which still holds (`isPrivate: true` at the time of this run) and still means the GitHub-sourced shields render broken. Neither asserts outstanding work. - -## Escalations - -Two hub findings surfaced by the resync this report measures, filed rather than patched per repo, both closed before this run, so neither is a finding of the current audit: - -1. #721: `prose_lint.py dead-path` could not recognize a hub-hosted path in a repo that retired the file, so verbatim text naming `repo-config/configure.sh` failed a downstream promotion gate. Fixed by #731. -2. #722: the hub's `.github/copilot-instructions.md` linked `GOVERNANCE.md#every-finding-ends-in-an-action`, an anchor that left `GOVERNANCE.md` when PR Review Etiquette was packaged as a Skill. PhotoCleaner re-pointed its copy in ptr727/PhotoCleaner#50 after Copilot raised it, and ptr727/PhotoCleaner#52 carried the canonical wording that #730 landed. +# Audit: PhotoCleaner + +- **Audited branch:** main (`f56178a`) +- **Types:** csharp, console, docker (from registry) +- **Verdict:** operational +- **Date:** 2026-08-15 +- **Run stamp:** `audit run 2026-08-15T18:43:20Z | hub b09078e` + +Supersedes the earlier 2026-08-15 snapshot, which measured `main` before the promotion. The resyncs against the hub (ptr727/PhotoCleaner#49, ptr727/PhotoCleaner#50, ptr727/PhotoCleaner#52) are promoted to `main` by ptr727/PhotoCleaner#51, and the deterministic audit reports **clean** at `main` and at `develop` with the hub at `b09078e`. The two hub-only workflow findings the earlier snapshot carried are gone as well, since the hub now declares the publisher and validator workflows at intent fidelity (#736). + +## Develop Drift + +`develop` vs `main`: identical content, since ptr727/PhotoCleaner#51 merged `develop` at `ab23034` and nothing has landed since. The audit at `develop` reports zero findings. + +The promotion was blocked for one round by the prose gate's `dead-path` rule flagging verbatim mentions of the retired `repo-config/configure.sh`, filed as #721 and fixed by #731. The gate fetches its rules from hub `develop` on a `develop`-targeted run, so a re-run cleared it with no repo change. Copilot read 18 of 19 changed files on the promotion in both rounds and raised no threads. Its one suppressed finding, a Mermaid edge in the hub-carried `WORKFLOW.md` diagram, was answered on the pull request as declined, since the diagram is hub content and the D1 text beside it already states the rule. + +## Dimensions + +Judged at `main`. Type-dimension checks are hand-judged per AUDIT.md section 4 and are unchanged from the 2026-08-01 snapshot, since ptr727/PhotoCleaner#49, ptr727/PhotoCleaner#50, and ptr727/PhotoCleaner#52 touched governance and configuration only. + +| Dimension | Letter | Intent | Verdict | Evidence (file:line) | +| --- | --- | --- | --- | --- | +| csharp | pass | pass | pass | `Directory.Build.props` carries the analyzer set and `TreatWarningsAsErrors`, `Directory.Packages.props` centralizes versions, `.editorconfig` carries the shared `[*.cs]` block | +| console | pass | pass | pass | System.CommandLine console (`PhotoCleaner/PhotoCleaner.csproj`, `OutputType=Exe`, net10.0), `build-executable-task.yml` aggregates per-runtime output to `release-asset-<branch>-*`, gated `!smoke` | +| docker | pass | pass | pass | `Docker/Dockerfile` multi-arch, `Docker/README.md` present, `build-docker-task.yml` uses a registry layer cache (`buildcache-<branch>`), the image re-pushes on publish | +| branch-model | pass | pass | pass | `repo-config/configure.sh check ptr727/PhotoCleaner release` at hub `0e84805`: both rulesets and every general setting match, nothing to apply | +| repo-setup | pass | pass | pass | Every name in `spec/secrets.json` present in both the Actions and Dependabot stores (`CODECOV_TOKEN` included), no forbidden name | +| linter-parity | pass | pass | pass | `validate-task.yml` runs csharpier check, `dotnet format style --verify-no-changes`, markdownlint, cspell, actionlint, editorconfig-checker, and the hub prose gate. One config per linter | +| recurring-violations | pass | pass | pass | `prose_lint.py --diff origin/develop` clean on ptr727/PhotoCleaner#50, `repo_gate.py` clean on `develop` (the `eol-coverage` forward-declaration gap closed in ptr727/PhotoCleaner#50) | +| readme-structure | pass | pass | pass | Required sections present in order, reference names and link groups per `spec/readme-structure.md`, all mechanical README checks clean at `main` | +| workflow (WORKFLOW.md 5A/5B) | pass | pass | pass | `build-release-task.yml` carries `validate-release` and the re-vendored `github-release` job (interface contract satisfied by name), and `publish-release.yml` and `validate-task.yml` are declared intent carries as of hub #736 | +| agent-instruction-set | pass | pass | pass | Every verbatim `AGENTS.md`/`GOVERNANCE.md` section byte-matches the hub canonical, and every intent carry (`CODESTYLE.md`, `WORKFLOW.md`, `AUDIT.md`, `.github/copilot-instructions.md`, the linter configs) is reconciled against the hub's history in ptr727/PhotoCleaner#50 and ptr727/PhotoCleaner#52 with the repo's own adaptations kept | + +nuget, pypi, python: N/A (no packaging, no Python). + +## Defects (most severe first) + +None. + +## Drift Findings + +None. The deterministic audit is clean at `main` and `develop`, and no hand-judged dimension reports a letter miss. + +## Convergence in Flight + +None. ptr727/PhotoCleaner#50, ptr727/PhotoCleaner#52 (to `develop`) and ptr727/PhotoCleaner#51 (`develop -> main`) are merged. + +## Proposed Registry / Spec Updates + +- The `driftNotes` are unchanged. The first describes the publish shape (multi-arch Docker plus a github-release 7z, two-phase release), which still holds. The second records the private-for-now decision, which still holds (`isPrivate: true` at the time of this run) and still means the GitHub-sourced shields render broken. Neither asserts outstanding work. + +## Escalations + +Two hub findings surfaced by the resync this report measures, filed rather than patched per repo, both closed before this run, so neither is a finding of the current audit: + +1. #721: `prose_lint.py dead-path` could not recognize a hub-hosted path in a repo that retired the file, so verbatim text naming `repo-config/configure.sh` failed a downstream promotion gate. Fixed by #731. +2. #722: the hub's `.github/copilot-instructions.md` linked `GOVERNANCE.md#every-finding-ends-in-an-action`, an anchor that left `GOVERNANCE.md` when PR Review Etiquette was packaged as a Skill. PhotoCleaner re-pointed its copy in ptr727/PhotoCleaner#50 after Copilot raised it, and ptr727/PhotoCleaner#52 carried the canonical wording that #730 landed. diff --git a/reports/plexcleaner/audit.md b/reports/plexcleaner/audit.md index 0d172e7c..96b99073 100644 --- a/reports/plexcleaner/audit.md +++ b/reports/plexcleaner/audit.md @@ -1,73 +1,73 @@ -# Audit: PlexCleaner - -- **Audited branch:** main (`16f196e`) -- **Types:** csharp, console, docker, python (from registry) -- **Workflow model:** release -- **Verdict:** operational -- **Date:** 2026-08-15 -- **Run stamps:** `audit run 2026-08-15T15:13:26Z | hub d54862a` (the hub `develop` head, byte-identical to `main` at `0e84805`), and `audit run 2026-08-15T17:35:45Z | hub 0713008` after the two gap dispositions below were settled by declaring both files - -Supersedes the 2026-07-04 snapshot, which measured the repository before the `AGENTS.md` and `GOVERNANCE.md` split, before `repo-config/configure.sh` was retired, and before the README structure sweep, so it described drift that no longer exists. This report is measured rather than carried forward: every row below was re-read at `16f196e`, and each structural workflow divergence the old snapshot recorded was re-checked rather than assumed. Two of them are closed. - -The repository was resynced against the hub `main` at `0e84805` and promoted on this date (ptr727/PlexCleaner#922, ptr727/PlexCleaner#923, ptr727/PlexCleaner#924). - -## Develop Drift - -`develop` (`57d81f5`) is 0 ahead and 89 behind `main`, and the two trees are **identical** (`git diff main develop` is empty). The count is the promotion-merge ancestry artifact this repository's merge model produces (squash into `develop`, merge commit into `main`), so it is benign and not a finding. The 2026-07-04 snapshot reported `develop` as diverged, which the promotions since have closed. - -## Dimensions - -The deterministic subset is `spec/audit.py` at the run stamps above, and both runs report **0 defect, letter, or error findings**. The first also raised two `hub-only` drift items, the fleet-wide ledger question rather than this repository's. The second, with both files now declared at `intent`, replaces them with two staleness advisories, and reading each against the hub retires both, see Drift Finding 6. The judgment rows are hand-read at `16f196e`. - -| Dimension | Letter | Intent | Verdict | Evidence (file:line) | -| --- | --- | --- | --- | --- | -| csharp | pass | pass | pass | `Directory.Build.props:6-9` carries `AnalysisLevel=latest-all`, `AnalysisMode=All`, `EnableNETAnalyzers`, and `TreatWarningsAsErrors`, with `ManagePackageVersionsCentrally` at `:12` and versions in `Directory.Packages.props`. `.editorconfig:72` opens the shared `[*.cs]` block, whose repo-wide CA relaxations each carry a reason and are scoped to a console application. Coverage is collected and uploaded at `.github/workflows/validate-task.yml:36,39-40` | -| console | pass | pass | pass | `PlexCleaner/PlexCleaner.csproj:16` is `OutputType=Exe` on `System.CommandLine` (`Directory.Packages.props:17`). The smoke matrix `["linux-x64","win-x64"]` is a strict subset of the seven-runtime full matrix (`build-executable-task.yml:44`), and the per-runtime outputs aggregate into one `release-asset-<branch>-executable` (`:103-107`), with every upload gated `!inputs.smoke` (`:75,86`) | -| docker | pass | pass | pass | Registry layer cache keyed `buildcache-<branch>`, never `type=gha`, reading both branches and writing only the built one on push (`build-docker-task.yml:92-95`). The trimmed overview publishes from `Docker/README.md` on `main` only (`:105-113`). The image push is gated on `inputs.push` alone, independent of the release-create skip | -| python | pass | pass | pass | Lint-only profile: `RegressionTests/pyproject.toml` carries `[tool.ruff]` and `[tool.mypy]` and no `[project]` table, so there is no lockfile to pin. The tools run through `uvx` at pinned versions, `uvx ruff@0.15.22 check .`, `format --check .`, and `uvx mypy@2.3.0 .` (`validate-task.yml:98-100`), which is `python.scripts.uvx` satisfied. `python.uvlock.pinned` and `python.coverage.codecov` are N/A for this subtree, as the registry records | -| branch-model | pass | pass | pass | `repo-config/configure.sh check ptr727/PlexCleaner release` from the hub checkout at `d54862a` reports every ruleset rule and every general setting matching the carried payloads, with nothing to apply | -| carried-scope | pass | pass | pass | `repo-config/configure.sh` is retired from the tree, and no other hub-hosted path is carried. The two `investigate` hits were the fleet-wide question settled in this change, and both files are now declared carries rather than gaps, so this repository's copies are measured instead of unclassified, see Proposed Registry / Spec Updates | -| repo-setup | pass | pass | pass | Every name in `spec/secrets.json` is present in both stores with no forbidden name, per the mechanized run. `.github/dependabot.yml` declares `nuget` and `github-actions` against both `main` and `develop`, which covers every implied ecosystem (`docker` and `devcontainers` are not implied here). `host-tools.json` declares the .NET 10 SDK alone, with the media tools argued as runtime rather than host dependencies | -| linter-parity | pass | pass | pass | One config per linter drives editor, CLI, and CI: csharpier and `dotnet format style --verify-no-changes` (`validate-task.yml:64-71`), markdownlint-cli2 and cspell (`:73-85`), ruff and mypy (`:91-100`), actionlint and editorconfig-checker (`:102-106`). The `.husky/pre-commit` hook runs the C# half natively and the Markdown half through already-present Docker images | -| recurring-violations | drift | pass | drift | The tree passes the mechanized `eol` check and carries no charset violation in text written since the rules took effect, but a whole-tree run of the hub prose gate reports 772 findings and `repo_gate.py --check eol-coverage` reports one. Neither is swept in CI here, because this repository consumes no prose gate. See Drift Findings 1 and 2 | -| readme-structure | pass | pass | pass | Every required section, shield class, reference name, and third-party-tool entry passes the mechanized `readme.*` checks at `16f196e`, closing the two letters the 2026-07-04 snapshot recorded | -| agent-instruction-set | pass | pass | pass | Every declared `AGENTS.md` and `GOVERNANCE.md` verbatim section matches the current canonical, and no intent carry is flagged. `CODESTYLE.md` carries the General plus Skill-pointer shape with `PlexCleaner .NET Conventions` (`:44`) and `PlexCleaner Python Conventions` (`:63`) as its repository-owned subsections. `OPERATIONS.md` and `host-tools.json` are present | -| workflow (WORKFLOW.md 5A/5B) | drift | pass | drift | Every applicable D-guarantee holds by outcome. Two structural divergences from the letter stand and two are closed, see the re-reads below and Drift Findings 3 and 4 | - -nuget, pypi, hugo, homeassistant, eda, upstream-wrapper, codegen: N/A (no package publish, no site, no upstream tracker, no codegen). - -## WORKFLOW.md 5A Re-Reads (only where the 2026-07-04 snapshot recorded a divergence) - -- **D2.1 and D2.2 (validate at entry, both directions):** now **pass**. `build-release-task.yml:62-88` is a dedicated `validate-release` entry job that strips `+buildmetadata`, fails a `main` version carrying a prerelease suffix **and** a non-`main` version carrying none, and exits early on smoke while the job still reports success. `build-executable` carries it in `needs:` and gates on its result (`:94`). The old snapshot's one-directional gate embedded as a step is closed. -- **D5.1 (delete at the point of consumption):** now **pass**. `build-release-task.yml:201-215` deletes the `release-asset-<branch>-*` transfer artifacts by pattern once they are attached to the release, gated to the same condition as the create step and `continue-on-error`, with `retention-days: 1` still the backstop. The caller grants `actions: write` (`publish-release.yml:38-39`). -- **D1.1, D1.4, and D1.5 (paths-filter):** still a letter miss. There is no `changes` job, so `test-pull-request.yml:13-14` smoke-builds both targets on every push, a docs-only or workflow-only one included, and the aggregator reads `needs: [validate, smoke-build]` (`:52`) rather than a `changes` job. The failure D1.1 exists to prevent, a changed target slipping through unbuilt, cannot occur when both targets always build, so the cost is runner time rather than coverage. -- **D2.3 (dispatch guard):** still a letter miss. `publish-release.yml:33` gates the publish job on `github.ref_name == 'main' || github.ref_name == 'develop'`, so a dispatch from any other ref **skips silently** where D2.3 wants a fail-fast `::error::`. S8 then reports success on a run that published nothing. -- **D4.1 (gated single-branch publish):** the outcome holds and the canonical shape has moved underneath it. The publisher is `workflow_dispatch` plus a weekly `main` schedule with no push trigger (`publish-release.yml:12-16`), so a human merge never auto-publishes, which is the guarantee. It carries no `publish-plan-task.yml` `plan` job and no code-affecting bot-push path, both of which the current D4.1 names, so a Dependabot bump merged to `main` ships in the next scheduled run rather than immediately. -- **D3.1 (version from the checked-out branch):** unchanged and still correct here. NBGV runs once in `get-version` with no `IGNORE_GITHUB_REF`, which is sound only while one run builds one branch (`publish-release.yml:42-43` pins `github.sha` and passes `github.ref_name`). A future both-branch matrix would need the flag. -- **D7.1, D9.1, and D9.4:** unchanged and passing (a ref-independent concurrency group with `cancel-in-progress: false` at `publish-release.yml:20-22`, every action SHA-pinned with `dotnet/nbgv@master` as the sanctioned exception, and the branch-scoped registry buildcache). - -## Defects (most severe first) - -None. No applicable check fails both letter and intent. - -## Drift Findings - -1. **CI runs no prose gate, and the tree carries the backlog that follows.** The repository vendors no prose gate and consumes none, so nothing checks a new line against the fleet prose rules at any point (`validate-task.yml` runs no such step, and `.husky/pre-commit` runs markdownlint and cspell only). A whole-tree run of the hub's `scripts/prose_lint.py` reports 772 findings, most of them `comment-wrap` (342), `comment-case` (135), `semicolon` (120), and `dash` (82), concentrated in the C# sources and in text written before the rules existed. The fleet remedy is the diff-scoped gate rather than a tree-wide sweep, so consuming the hub's `prose-gate` action in `validate-task.yml`, as PhotoCleaner does, bounds the backlog at today's size and leaves the rest to the fix-as-edited policy. Filed as ptr727/PlexCleaner#925. Two findings are substantive rather than stylistic and can be fixed on sight: `RegressionTests/RegressionTest.sh:75,361` carry the British `behaviour`, and `PlexCleaner/VideoProps.cs:138` duplicates `not`. -2. **`.gitattributes:29` pins `*.Dockerfile` to LF and no tracked file matches it.** `repo_gate.py --check eol-coverage` fails on exactly this. The repository consolidated on a single `Docker/Dockerfile`, which the `:28` pin already covers, so the `*.Dockerfile` line is either dropped or marked forward-declared the way the `uv.lock` block above it is. Filed as ptr727/PlexCleaner#926. -3. **No `changes` paths-filter (D1.1, D1.4, D1.5).** Deliberate, and documented in the workflow header. It over-builds on a docs-only or workflow-only push and cannot under-build. -4. **The dispatch guard skips rather than failing fast (D2.3).** `publish-release.yml:33`. A dispatch from a feature branch is a green run that published nothing, which reads as a successful release. -5. **Two `GOVERNANCE.md` mentions name a path this tree no longer holds.** `GOVERNANCE.md:29` names `repo-config/configure.sh`, which is the hub-hosted pointer that rule requires rather than a stale mention, and is the false positive filed as #721 and fixed in #731. `GOVERNANCE.md:282` names `.vscode/settings.json`, which this repository genuinely deleted, so that one is a real stale mention for the file's next edit. -6. **Both `intent` staleness advisories on the newly declared workflows are false trails, read and retired here.** The advisory is date-based by design, a hint rather than proof, and `intent` equivalence is a human call. `validate-task.yml` last changed 2026-08-05 against a hub canonical that moved on 2026-08-14, but that hub change added `ruff format --check` and this copy already runs it, at a pinned version rather than `@latest` per its own convention (`validate-task.yml:99`). `publish-release.yml` last changed 2026-07-24 against a hub canonical that moved on 2026-08-08, and that hub change was a Dependabot action-pin bump, which is per-repo drift by definition. Neither copy trails the hub in substance. The publisher case is not this repository's at all: the advisory has no action-pin normalization, so a Dependabot bump in the hub marks every carrier at once, 17 of them in this run, filed as #735. - -## Proposed Registry / Spec Updates - -- **Delete the branch-hygiene `driftNote`.** It reads "3 stale Dependabot nuget branches (PRs closed/superseded) and an unmerged feature/727-decouple-release-assets branch linger". The Dependabot branches are gone, and the live list now holds seven branches beyond `main` and `develop` rather than four, so the note describes neither the old state nor the new one. Branch hygiene is also not a baseline deviation any check scores, which is what a `driftNote` records, so it is deleted rather than re-snapshotted into prose that rots the same way. Applied in this change. -- **The other two `driftNotes` stand.** `ARCHITECTURE.md` and `codecov.yml` are both carried beyond the baseline, and the csharp-plus-python shape note is accurate down to its details (the `RegressionTests/` subtree, no `uv.lock`, a `pyproject.toml` holding only ruff and mypy config, and the two N/A check ids). -- **`status`, `types`, `publish`, `requiredSecrets`, `consumerModel`, `releaseTrigger`, and `configLayout` are unchanged and accurate.** -- **Settle both `investigate` gap dispositions by declaring the files at `intent` fidelity.** Applied in this change, and it is a fleet decision rather than this repository's, taken by the maintainer on the measurement below. `spec/files.json` gains `.github/workflows/publish-release.yml` (`appliesTo` the three publishing `releaseTrigger` values) and `.github/workflows/validate-task.yml` (`appliesTo` `*`), and both ledger rows are deleted, since a declared file is no longer a gap. What the measurement settled is the fidelity rather than the question of carrying: reading every cataloged repository's copy on its ground-truth branch shows the publisher's job keys are per-repo (`publish` in 15 of the 18 copies read, against `create-release`, `publish-pypi`, and an eight-job orchestration elsewhere) and its triggers run from dispatch-only to dispatch plus schedule plus push, while `validate-task.yml` is uniformly `on: workflow_call` over a per-repo job list in six distinct shapes. No `interface` contract fits that spread, and `intent` is the tier that does: presence asserted, equivalence judged by hand against the `intentRef`, and a staleness advisory raised when the hub's copy moves past a repository's, which is what keeps a copy close to the canonical rather than merely present. The publisher's scope is exact, since every repository lacking one carries `releaseTrigger: none` and all 18 carriers carry a publishing trigger. The validator is declared fleet-wide by decision rather than by measurement, so the eight repositories that inline their validation into `test-pull-request.yml` now carry a presence finding, which is the convergence this asks for rather than noise. -- **Set the `csharp` plus `python` row's Last audited to 2026-08-15** in `reports/conformance-matrix.md`. Applied in this change. - -## Escalations - -- **#721, fixed in #731 rather than here.** `prose_lint.py --check dead-path` cannot recognize a hub-hosted path in a repository that retired the file, so `GOVERNANCE.md:29` here reports as a dead path. This repository is a second carrier of the finding PhotoCleaner hit at its promotion gate. -- **A changelog legitimately names paths the tree no longer holds, and `dead-path` reports them.** `HISTORY.md:110,137` name three Dockerfiles and a script that later releases removed, which is what a changelog records. The finding fails no gate today, because the gate is diff-scoped and those lines are old, so this is recorded rather than filed. It is the same class as #721 and worth folding in if a third instance appears. +# Audit: PlexCleaner + +- **Audited branch:** main (`16f196e`) +- **Types:** csharp, console, docker, python (from registry) +- **Workflow model:** release +- **Verdict:** operational +- **Date:** 2026-08-15 +- **Run stamps:** `audit run 2026-08-15T15:13:26Z | hub d54862a` (the hub `develop` head, byte-identical to `main` at `0e84805`), and `audit run 2026-08-15T17:35:45Z | hub 0713008` after the two gap dispositions below were settled by declaring both files + +Supersedes the 2026-07-04 snapshot, which measured the repository before the `AGENTS.md` and `GOVERNANCE.md` split, before `repo-config/configure.sh` was retired, and before the README structure sweep, so it described drift that no longer exists. This report is measured rather than carried forward: every row below was re-read at `16f196e`, and each structural workflow divergence the old snapshot recorded was re-checked rather than assumed. Two of them are closed. + +The repository was resynced against the hub `main` at `0e84805` and promoted on this date (ptr727/PlexCleaner#922, ptr727/PlexCleaner#923, ptr727/PlexCleaner#924). + +## Develop Drift + +`develop` (`57d81f5`) is 0 ahead and 89 behind `main`, and the two trees are **identical** (`git diff main develop` is empty). The count is the promotion-merge ancestry artifact this repository's merge model produces (squash into `develop`, merge commit into `main`), so it is benign and not a finding. The 2026-07-04 snapshot reported `develop` as diverged, which the promotions since have closed. + +## Dimensions + +The deterministic subset is `spec/audit.py` at the run stamps above, and both runs report **0 defect, letter, or error findings**. The first also raised two `hub-only` drift items, the fleet-wide ledger question rather than this repository's. The second, with both files now declared at `intent`, replaces them with two staleness advisories, and reading each against the hub retires both, see Drift Finding 6. The judgment rows are hand-read at `16f196e`. + +| Dimension | Letter | Intent | Verdict | Evidence (file:line) | +| --- | --- | --- | --- | --- | +| csharp | pass | pass | pass | `Directory.Build.props:6-9` carries `AnalysisLevel=latest-all`, `AnalysisMode=All`, `EnableNETAnalyzers`, and `TreatWarningsAsErrors`, with `ManagePackageVersionsCentrally` at `:12` and versions in `Directory.Packages.props`. `.editorconfig:72` opens the shared `[*.cs]` block, whose repo-wide CA relaxations each carry a reason and are scoped to a console application. Coverage is collected and uploaded at `.github/workflows/validate-task.yml:36,39-40` | +| console | pass | pass | pass | `PlexCleaner/PlexCleaner.csproj:16` is `OutputType=Exe` on `System.CommandLine` (`Directory.Packages.props:17`). The smoke matrix `["linux-x64","win-x64"]` is a strict subset of the seven-runtime full matrix (`build-executable-task.yml:44`), and the per-runtime outputs aggregate into one `release-asset-<branch>-executable` (`:103-107`), with every upload gated `!inputs.smoke` (`:75,86`) | +| docker | pass | pass | pass | Registry layer cache keyed `buildcache-<branch>`, never `type=gha`, reading both branches and writing only the built one on push (`build-docker-task.yml:92-95`). The trimmed overview publishes from `Docker/README.md` on `main` only (`:105-113`). The image push is gated on `inputs.push` alone, independent of the release-create skip | +| python | pass | pass | pass | Lint-only profile: `RegressionTests/pyproject.toml` carries `[tool.ruff]` and `[tool.mypy]` and no `[project]` table, so there is no lockfile to pin. The tools run through `uvx` at pinned versions, `uvx ruff@0.15.22 check .`, `format --check .`, and `uvx mypy@2.3.0 .` (`validate-task.yml:98-100`), which is `python.scripts.uvx` satisfied. `python.uvlock.pinned` and `python.coverage.codecov` are N/A for this subtree, as the registry records | +| branch-model | pass | pass | pass | `repo-config/configure.sh check ptr727/PlexCleaner release` from the hub checkout at `d54862a` reports every ruleset rule and every general setting matching the carried payloads, with nothing to apply | +| carried-scope | pass | pass | pass | `repo-config/configure.sh` is retired from the tree, and no other hub-hosted path is carried. The two `investigate` hits were the fleet-wide question settled in this change, and both files are now declared carries rather than gaps, so this repository's copies are measured instead of unclassified, see Proposed Registry / Spec Updates | +| repo-setup | pass | pass | pass | Every name in `spec/secrets.json` is present in both stores with no forbidden name, per the mechanized run. `.github/dependabot.yml` declares `nuget` and `github-actions` against both `main` and `develop`, which covers every implied ecosystem (`docker` and `devcontainers` are not implied here). `host-tools.json` declares the .NET 10 SDK alone, with the media tools argued as runtime rather than host dependencies | +| linter-parity | pass | pass | pass | One config per linter drives editor, CLI, and CI: csharpier and `dotnet format style --verify-no-changes` (`validate-task.yml:64-71`), markdownlint-cli2 and cspell (`:73-85`), ruff and mypy (`:91-100`), actionlint and editorconfig-checker (`:102-106`). The `.husky/pre-commit` hook runs the C# half natively and the Markdown half through already-present Docker images | +| recurring-violations | drift | pass | drift | The tree passes the mechanized `eol` check and carries no charset violation in text written since the rules took effect, but a whole-tree run of the hub prose gate reports 772 findings and `repo_gate.py --check eol-coverage` reports one. Neither is swept in CI here, because this repository consumes no prose gate. See Drift Findings 1 and 2 | +| readme-structure | pass | pass | pass | Every required section, shield class, reference name, and third-party-tool entry passes the mechanized `readme.*` checks at `16f196e`, closing the two letters the 2026-07-04 snapshot recorded | +| agent-instruction-set | pass | pass | pass | Every declared `AGENTS.md` and `GOVERNANCE.md` verbatim section matches the current canonical, and no intent carry is flagged. `CODESTYLE.md` carries the General plus Skill-pointer shape with `PlexCleaner .NET Conventions` (`:44`) and `PlexCleaner Python Conventions` (`:63`) as its repository-owned subsections. `OPERATIONS.md` and `host-tools.json` are present | +| workflow (WORKFLOW.md 5A/5B) | drift | pass | drift | Every applicable D-guarantee holds by outcome. Two structural divergences from the letter stand and two are closed, see the re-reads below and Drift Findings 3 and 4 | + +nuget, pypi, hugo, homeassistant, eda, upstream-wrapper, codegen: N/A (no package publish, no site, no upstream tracker, no codegen). + +## WORKFLOW.md 5A Re-Reads (only where the 2026-07-04 snapshot recorded a divergence) + +- **D2.1 and D2.2 (validate at entry, both directions):** now **pass**. `build-release-task.yml:62-88` is a dedicated `validate-release` entry job that strips `+buildmetadata`, fails a `main` version carrying a prerelease suffix **and** a non-`main` version carrying none, and exits early on smoke while the job still reports success. `build-executable` carries it in `needs:` and gates on its result (`:94`). The old snapshot's one-directional gate embedded as a step is closed. +- **D5.1 (delete at the point of consumption):** now **pass**. `build-release-task.yml:201-215` deletes the `release-asset-<branch>-*` transfer artifacts by pattern once they are attached to the release, gated to the same condition as the create step and `continue-on-error`, with `retention-days: 1` still the backstop. The caller grants `actions: write` (`publish-release.yml:38-39`). +- **D1.1, D1.4, and D1.5 (paths-filter):** still a letter miss. There is no `changes` job, so `test-pull-request.yml:13-14` smoke-builds both targets on every push, a docs-only or workflow-only one included, and the aggregator reads `needs: [validate, smoke-build]` (`:52`) rather than a `changes` job. The failure D1.1 exists to prevent, a changed target slipping through unbuilt, cannot occur when both targets always build, so the cost is runner time rather than coverage. +- **D2.3 (dispatch guard):** still a letter miss. `publish-release.yml:33` gates the publish job on `github.ref_name == 'main' || github.ref_name == 'develop'`, so a dispatch from any other ref **skips silently** where D2.3 wants a fail-fast `::error::`. S8 then reports success on a run that published nothing. +- **D4.1 (gated single-branch publish):** the outcome holds and the canonical shape has moved underneath it. The publisher is `workflow_dispatch` plus a weekly `main` schedule with no push trigger (`publish-release.yml:12-16`), so a human merge never auto-publishes, which is the guarantee. It carries no `publish-plan-task.yml` `plan` job and no code-affecting bot-push path, both of which the current D4.1 names, so a Dependabot bump merged to `main` ships in the next scheduled run rather than immediately. +- **D3.1 (version from the checked-out branch):** unchanged and still correct here. NBGV runs once in `get-version` with no `IGNORE_GITHUB_REF`, which is sound only while one run builds one branch (`publish-release.yml:42-43` pins `github.sha` and passes `github.ref_name`). A future both-branch matrix would need the flag. +- **D7.1, D9.1, and D9.4:** unchanged and passing (a ref-independent concurrency group with `cancel-in-progress: false` at `publish-release.yml:20-22`, every action SHA-pinned with `dotnet/nbgv@master` as the sanctioned exception, and the branch-scoped registry buildcache). + +## Defects (most severe first) + +None. No applicable check fails both letter and intent. + +## Drift Findings + +1. **CI runs no prose gate, and the tree carries the backlog that follows.** The repository vendors no prose gate and consumes none, so nothing checks a new line against the fleet prose rules at any point (`validate-task.yml` runs no such step, and `.husky/pre-commit` runs markdownlint and cspell only). A whole-tree run of the hub's `scripts/prose_lint.py` reports 772 findings, most of them `comment-wrap` (342), `comment-case` (135), `semicolon` (120), and `dash` (82), concentrated in the C# sources and in text written before the rules existed. The fleet remedy is the diff-scoped gate rather than a tree-wide sweep, so consuming the hub's `prose-gate` action in `validate-task.yml`, as PhotoCleaner does, bounds the backlog at today's size and leaves the rest to the fix-as-edited policy. Filed as ptr727/PlexCleaner#925. Two findings are substantive rather than stylistic and can be fixed on sight: `RegressionTests/RegressionTest.sh:75,361` carry the British `behaviour`, and `PlexCleaner/VideoProps.cs:138` duplicates `not`. +2. **`.gitattributes:29` pins `*.Dockerfile` to LF and no tracked file matches it.** `repo_gate.py --check eol-coverage` fails on exactly this. The repository consolidated on a single `Docker/Dockerfile`, which the `:28` pin already covers, so the `*.Dockerfile` line is either dropped or marked forward-declared the way the `uv.lock` block above it is. Filed as ptr727/PlexCleaner#926. +3. **No `changes` paths-filter (D1.1, D1.4, D1.5).** Deliberate, and documented in the workflow header. It over-builds on a docs-only or workflow-only push and cannot under-build. +4. **The dispatch guard skips rather than failing fast (D2.3).** `publish-release.yml:33`. A dispatch from a feature branch is a green run that published nothing, which reads as a successful release. +5. **Two `GOVERNANCE.md` mentions name a path this tree no longer holds.** `GOVERNANCE.md:29` names `repo-config/configure.sh`, which is the hub-hosted pointer that rule requires rather than a stale mention, and is the false positive filed as #721 and fixed in #731. `GOVERNANCE.md:282` names `.vscode/settings.json`, which this repository genuinely deleted, so that one is a real stale mention for the file's next edit. +6. **Both `intent` staleness advisories on the newly declared workflows are false trails, read and retired here.** The advisory is date-based by design, a hint rather than proof, and `intent` equivalence is a human call. `validate-task.yml` last changed 2026-08-05 against a hub canonical that moved on 2026-08-14, but that hub change added `ruff format --check` and this copy already runs it, at a pinned version rather than `@latest` per its own convention (`validate-task.yml:99`). `publish-release.yml` last changed 2026-07-24 against a hub canonical that moved on 2026-08-08, and that hub change was a Dependabot action-pin bump, which is per-repo drift by definition. Neither copy trails the hub in substance. The publisher case is not this repository's at all: the advisory has no action-pin normalization, so a Dependabot bump in the hub marks every carrier at once, 17 of them in this run, filed as #735. + +## Proposed Registry / Spec Updates + +- **Delete the branch-hygiene `driftNote`.** It reads "3 stale Dependabot nuget branches (PRs closed/superseded) and an unmerged feature/727-decouple-release-assets branch linger". The Dependabot branches are gone, and the live list now holds seven branches beyond `main` and `develop` rather than four, so the note describes neither the old state nor the new one. Branch hygiene is also not a baseline deviation any check scores, which is what a `driftNote` records, so it is deleted rather than re-snapshotted into prose that rots the same way. Applied in this change. +- **The other two `driftNotes` stand.** `ARCHITECTURE.md` and `codecov.yml` are both carried beyond the baseline, and the csharp-plus-python shape note is accurate down to its details (the `RegressionTests/` subtree, no `uv.lock`, a `pyproject.toml` holding only ruff and mypy config, and the two N/A check ids). +- **`status`, `types`, `publish`, `requiredSecrets`, `consumerModel`, `releaseTrigger`, and `configLayout` are unchanged and accurate.** +- **Settle both `investigate` gap dispositions by declaring the files at `intent` fidelity.** Applied in this change, and it is a fleet decision rather than this repository's, taken by the maintainer on the measurement below. `spec/files.json` gains `.github/workflows/publish-release.yml` (`appliesTo` the three publishing `releaseTrigger` values) and `.github/workflows/validate-task.yml` (`appliesTo` `*`), and both ledger rows are deleted, since a declared file is no longer a gap. What the measurement settled is the fidelity rather than the question of carrying: reading every cataloged repository's copy on its ground-truth branch shows the publisher's job keys are per-repo (`publish` in 15 of the 18 copies read, against `create-release`, `publish-pypi`, and an eight-job orchestration elsewhere) and its triggers run from dispatch-only to dispatch plus schedule plus push, while `validate-task.yml` is uniformly `on: workflow_call` over a per-repo job list in six distinct shapes. No `interface` contract fits that spread, and `intent` is the tier that does: presence asserted, equivalence judged by hand against the `intentRef`, and a staleness advisory raised when the hub's copy moves past a repository's, which is what keeps a copy close to the canonical rather than merely present. The publisher's scope is exact, since every repository lacking one carries `releaseTrigger: none` and all 18 carriers carry a publishing trigger. The validator is declared fleet-wide by decision rather than by measurement, so the eight repositories that inline their validation into `test-pull-request.yml` now carry a presence finding, which is the convergence this asks for rather than noise. +- **Set the `csharp` plus `python` row's Last audited to 2026-08-15** in `reports/conformance-matrix.md`. Applied in this change. + +## Escalations + +- **#721, fixed in #731 rather than here.** `prose_lint.py --check dead-path` cannot recognize a hub-hosted path in a repository that retired the file, so `GOVERNANCE.md:29` here reports as a dead path. This repository is a second carrier of the finding PhotoCleaner hit at its promotion gate. +- **A changelog legitimately names paths the tree no longer holds, and `dead-path` reports them.** `HISTORY.md:110,137` name three Dockerfiles and a script that later releases removed, which is what a changelog records. The finding fails no gate today, because the gate is diff-scoped and those lines are old, so this is recorded rather than filed. It is the same class as #721 and worth folding in if a third instance appears. diff --git a/reports/utilities/audit.md b/reports/utilities/audit.md index 97abf87f..e192714e 100644 --- a/reports/utilities/audit.md +++ b/reports/utilities/audit.md @@ -1,77 +1,77 @@ -# Audit: Utilities - -- **Audited branch:** main (`8de105fd101c6ce4b60447bc1d99b5e6ab08683e`) -- **Types:** csharp, nuget (from registry) -- **Verdict:** operational -- **Date:** 2026-07-04 - -## Develop Drift - -`develop` vs `main`: ahead 0, behind 28 (`gh api repos/ptr727/Utilities/compare/main...develop` -> `status: behind, ahead_by: 0, behind_by: 28`). **Stale - a drift finding.** `develop` has none of `main`'s 28 commits, including the CI/CD rework that is the audited `main` state. Under the forward-only model (no `main -> develop` back-merge) `develop` must receive those changes directly; it has not, so `develop` does not reflect the released pipeline. - -## Dimensions - -| Dimension | Letter | Intent | Verdict | Evidence (file:line) | -| --- | --- | --- | --- | --- | -| csharp | pass | pass | pass | `.editorconfig:64` carries the shared `[*.cs]`+ReSharper block (verbatim vs template); analyzers enforced in `Directory.Build.props:6-9` (`AnalysisLevel=latest-all`, `AnalysisMode=All`, `EnableNETAnalyzers=true`, `TreatWarningsAsErrors=true`); suppressions narrow and documented per project (`Utilities/.editorconfig:9-27`, `UtilitiesTests/.editorconfig:12-32`), not a blanket relax | -| nuget | pass | pass | pass | OIDC login (`NuGet/login`) `build-release-task.yml:131-136`, ephemeral key at `:145`, no stored `NUGET_API_KEY`; `dotnet nuget push --skip-duplicate` `:143-146`, gated `if: inputs.publish && !inputs.smoke` `:140` (push-gated, not existence-gated); `.snupkg` symbols pushed `Utilities/Utilities.csproj:29-30` | -| pypi | - | - | N/A | no `pyproject.toml` / `gh-action-pypi-publish` | -| python | - | - | N/A | no Python project | -| console | - | - | N/A | shipped target is the packable library `Utilities/Utilities.csproj:10-11`; no `build-executable-task` / System.CommandLine app (`Sandbox` is a non-shipped scratch project) | -| docker | - | - | N/A | no `Dockerfile` / docker build task | -| branch-model | drift | pass | drift | both branches protected; the required-status-check `context` `Check pull request workflow status job` is the fleet canonical (adopted template-wide) and matches Utilities' own aggregator `test-pull-request.yml:45`, so the naming is **not** a drift; residual drift is the off-baseline `repo-config/ruleset-{develop,main}.json` filenames + committed files omitting `bypass_actors` (see Drift) | -| repo-setup | pass | pass | pass | actions + dependabot stores hold `CODEGEN_APP_CLIENT_ID`, `CODEGEN_APP_PRIVATE_KEY`, `NUGET_USERNAME`; no forbidden `NUGET_API_KEY` (nuget-oidc) or `CODEGEN_APP_ID` (codegen-app); nuget-oidc needs no stored secret | -| linter-parity | pass | pass | pass | one `.markdownlint-cli2.jsonc` + `cspell.json` drive editor + CLI + CI (`validate-task.yml:56-69`); editorconfig/csharpier shared (`.vscode/tasks.json`, `.config/dotnet-tools.json`); CI runs each linter | -| recurring-violations | pass | pass | pass | comments concise; docs ASCII-clean (only non-ASCII is a deliberate warning emoji `Utilities/FileEx.cs:1090` and Unicode test fixtures `UtilitiesTests/ExtensionsTests.cs:231`); `cspell.json:3` sets `"language": "en-US"`; real endings compliant (CRLF docs/workflows, LF `.husky/pre-commit`). See Drift re `.editorconfig` global default | -| readme-structure | drift | drift | drift | sections mostly present and ordered but omits `## Table of Contents` and `## Questions or Issues`, and uses `## Contributing` for the Development-Environment-Setup slot; minor: missing colon `README.md:9`, shields header `<!-- Shields links -->` vs canonical `<!-- Shields -->` `README.md:65` | -| workflow (WORKFLOW.md 5A/5B) | drift | pass | drift | all applicable D-guarantees hold by **outcome**; divergences are structural (see below): continuous per-branch push-publish instead of two-phase scheduled matrix, no `changes` paths-filter, plain `release-asset` name + exact-name download, keys off `github.ref_name` instead of `inputs.branch` | - -## WORKFLOW.md 5A Static Audit (applicable D-guarantees) - -- **D1.2 / D1.5 (validation + aggregator):** PASS - `validate` (unit-test + lint) runs unconditionally `test-pull-request.yml:25-28`; aggregator `needs: [validate, smoke-build]`, requires `success` `:44-60`; the ruleset-bound name is kept in lockstep `:45`. -- **D1.1 / D1.4 (paths-filter):** DRIFT - no `changes` / `dorny/paths-filter` job; `smoke-build` runs on every push including docs/workflow-only `test-pull-request.yml:32-40`. Deliberate over-build; the "target slips unbuilt" failure mode cannot occur with a single always-built target. -- **D1.3 (smoke uploads/pushes nothing):** PASS - every push/upload gated `if: inputs.publish && !inputs.smoke` `build-release-task.yml:132,140,150,163`. -- **D2.1 / D2.2 (validate-at-entry):** PASS - `validate-release` asserts branch<->classification both directions, strips `+buildmetadata`, skips on smoke `build-release-task.yml:65-94`; downstream `needs:` it. -- **D2.3 (dispatch guard):** DRIFT - the publish job `if:` silently skips a dispatch from a non-main/develop ref `publish-release.yml:47` (template fails fast with `::error::`; skip is safe but non-canonical). -- **D3.1 / D3.2 (classification):** PASS by model - NBGV runs once on the triggering ref `build-release-task.yml:33-61`; config keys off `github.ref_name` `:120,213`, correct here because there is no cross-branch matrix building the other leg from a main-ref run. `version.json:4-6` `publicReleaseRefSpec ^refs/heads/main$`. -- **D3.4 (registry version per classification):** PASS - build injects `-property:PackageVersion=SemVer2` `build-release-task.yml:127`; NuGet.org derives prerelease from the `-g<sha>` suffix. -- **D4.1 (two-phase):** DRIFT - no schedule, no both-branch matrix; publishes on every push to main/develop touching shipped paths `publish-release.yml:16-24`. This is WORKFLOW.md's opt-in continuous-release mode used as the default (no `PUBLISH_ON_MERGE` variable exists). -- **D4.2 / D4.3 / D4.4 (release):** PASS - `target_commitish=GitCommitId` `:212`, `prerelease = ref_name != 'main'` `:213`, create gated `exists == false || workflow_dispatch` `:207`, `--skip-duplicate` no-op re-push `:146`. -- **D5 (cleanup):** PASS - asset delete at the consumer under the same gate as create, `continue-on-error`, filtered by name (not a blanket `.artifacts[].id`) `build-release-task.yml:223-238`; `retention-days: 1` on the one upload `:168`. -- **D6.1 / D6.3 (seam):** DRIFT - single asset named `release-asset` (not `release-asset-<branch>-<target>`) `:166`, downloaded by exact `name:` (not `pattern:` / `merge-multiple:`) `:184-188`. Works for one target; forks the canonical verbatim handoff. -- **D7.1 / D7.2 (concurrency/permissions):** PASS - publisher group ref-independent + `cancel-in-progress: false` `publish-release.yml:28-30`; reusable jobs inherit, caller grants `id-token` / `contents` / `actions: write` at the one entry point `:50-53`. -- **D9.1 (SHA-pinning):** PASS - all actions SHA-pinned with version comments; `dotnet/nbgv@master` is the sanctioned lagging-tag exception `build-release-task.yml:61`. -- **Wrapper / PyPI / Docker / console addenda:** N/A. - -## WORKFLOW.md 5B Trace (applicable scenarios) - -- **S1** (PR touches library): validate + smoke run, no push/upload, validate-release skipped (smoke), aggregator success, version prerelease. PASS. -- **S2 / S3** (docs-only / workflow-only PR): smoke-build **runs** (no paths-filter) instead of skipping; aggregator success - safe but over-builds. DRIFT. -- **S4** (PR base = main): smoke prerelease, validate-release skipped (smoke), promotion not blocked. PASS. -- **S5** (push not touching shipped paths): publish-release not triggered. PASS. -- **S6** (push to develop touching `Utilities/**`): publishes a develop **prerelease** by default, not opt-in. DRIFT (continuous release). -- **S7** (both-branch matrix publish): N/A - no schedule/matrix; a dispatch publishes only its own branch. -- **S8** (dispatch from a non-publishable ref): publish job skipped (template fails fast). DRIFT. -- **S9** (re-run, version unchanged): release-create + asset-delete skipped, NuGet `--skip-duplicate` no-op. PASS. -- **S10** (branch/version disagree): validate-release fails loud. PASS. -- **S11** (wrapper bump): N/A. - -## Defects (most severe first) - -None. No applicable check fails both letter and intent. - -## Drift Findings - -1. **Release model - continuous per-branch push-publish, not two-phase.** `publish-release.yml:16-24` triggers publish on every push to main/develop that touches shipped paths, with no weekly schedule and no both-branch matrix - WORKFLOW.md's opt-in continuous-release mode used as the default (no `PUBLISH_ON_MERGE` variable). Outcomes are correct (main -> stable, develop -> prerelease); the divergence is structural. `publish-release.yml:16-30`. -2. **Seam handoff not canonical.** Single artifact `release-asset` with exact-name download instead of `release-asset-<branch>-<target>` + `pattern:` / `merge-multiple:`; forks the verbatim `github-release` carry and will not extend to a second target without rework. `build-release-task.yml:166,184-188`. -3. **`.editorconfig` lacks the canonical global line-ending default.** `[*]` sets no `end_of_line`; CRLF is re-declared per file-type - the older per-extension form AGENTS.md warns against; any uncovered file type gets no CRLF default. Canonical form is `[*] end_of_line = crlf` + LF pins. `.editorconfig:1-22` (no `[*]` default), `:23-63`. (`.gitattributes:6,11-15` still enforces via `* -text` + LF pins, so real files are compliant.) -4. **Stale hardcoded package versions in the csproj.** `Utilities/Utilities.csproj:17-19` hardcodes `<Version>1.1.1.1</Version>`, `<FileVersion>1.1.1.1</FileVersion>`, `<AssemblyVersion>1.1.1.0</AssemblyVersion>`, contradicting the `version.json:3` floor `3.6`. CI overrides these via `-property:Version=SemVer2` (`build-release-task.yml:123-127`), so published packages are correct, but a local `dotnet pack` produces a wrong `1.1.1.1` package (no Nerdbank.GitVersioning PackageReference; NBGV is workflow-only). -5. **Ruleset naming matches the fleet canonical (not a drift).** The required-status-check `context` and the aggregator job `name:` are both `Check pull request workflow status job` (`test-pull-request.yml:45`) - the `<name> job` convention now adopted template-wide. This report predated that adoption; the naming is canonical, not a deviation. -6. **repo-config filenames off-baseline.** Committed as `repo-config/ruleset-develop.json` / `ruleset-main.json` (baseline expects `develop.json` / `main.json` per `spec/files.json`); the committed files also omit `bypass_actors`, though the **live** rulesets do carry `RepositoryRole 5 always` (matching template). -7. **README omissions.** No `## Table of Contents` heading, no `## Questions or Issues`; `## Contributing` occupies the Development-Environment-Setup slot; `README.md:9` missing colon; shields group header `<!-- Shields links -->` vs canonical `<!-- Shields -->`. -8. **develop stale.** See Develop Drift (28 behind, 0 ahead). - -## Proposed Registry / Spec Updates - -- Registry `Utilities` types `["csharp","nuget"]` and publish `nuget` via `oidc` are accurate; no change. Consider advancing `status` beyond `cataloged` once the drift findings are addressed. -- **Spec signal (candidate machine checks):** the required-status-check context-name divergence (`...status job`) and the per-extension `.editorconfig` shape may recur across derived repos. If several share them, the fix belongs in a spec/lint check - assert the ruleset `context` equals the workflow aggregator `name:`, and assert a global `[*] end_of_line` default exists - rather than per-repo notes. +# Audit: Utilities + +- **Audited branch:** main (`8de105fd101c6ce4b60447bc1d99b5e6ab08683e`) +- **Types:** csharp, nuget (from registry) +- **Verdict:** operational +- **Date:** 2026-07-04 + +## Develop Drift + +`develop` vs `main`: ahead 0, behind 28 (`gh api repos/ptr727/Utilities/compare/main...develop` -> `status: behind, ahead_by: 0, behind_by: 28`). **Stale - a drift finding.** `develop` has none of `main`'s 28 commits, including the CI/CD rework that is the audited `main` state. Under the forward-only model (no `main -> develop` back-merge) `develop` must receive those changes directly; it has not, so `develop` does not reflect the released pipeline. + +## Dimensions + +| Dimension | Letter | Intent | Verdict | Evidence (file:line) | +| --- | --- | --- | --- | --- | +| csharp | pass | pass | pass | `.editorconfig:64` carries the shared `[*.cs]`+ReSharper block (verbatim vs template); analyzers enforced in `Directory.Build.props:6-9` (`AnalysisLevel=latest-all`, `AnalysisMode=All`, `EnableNETAnalyzers=true`, `TreatWarningsAsErrors=true`); suppressions narrow and documented per project (`Utilities/.editorconfig:9-27`, `UtilitiesTests/.editorconfig:12-32`), not a blanket relax | +| nuget | pass | pass | pass | OIDC login (`NuGet/login`) `build-release-task.yml:131-136`, ephemeral key at `:145`, no stored `NUGET_API_KEY`; `dotnet nuget push --skip-duplicate` `:143-146`, gated `if: inputs.publish && !inputs.smoke` `:140` (push-gated, not existence-gated); `.snupkg` symbols pushed `Utilities/Utilities.csproj:29-30` | +| pypi | - | - | N/A | no `pyproject.toml` / `gh-action-pypi-publish` | +| python | - | - | N/A | no Python project | +| console | - | - | N/A | shipped target is the packable library `Utilities/Utilities.csproj:10-11`; no `build-executable-task` / System.CommandLine app (`Sandbox` is a non-shipped scratch project) | +| docker | - | - | N/A | no `Dockerfile` / docker build task | +| branch-model | drift | pass | drift | both branches protected; the required-status-check `context` `Check pull request workflow status job` is the fleet canonical (adopted template-wide) and matches Utilities' own aggregator `test-pull-request.yml:45`, so the naming is **not** a drift; residual drift is the off-baseline `repo-config/ruleset-{develop,main}.json` filenames + committed files omitting `bypass_actors` (see Drift) | +| repo-setup | pass | pass | pass | actions + dependabot stores hold `CODEGEN_APP_CLIENT_ID`, `CODEGEN_APP_PRIVATE_KEY`, `NUGET_USERNAME`; no forbidden `NUGET_API_KEY` (nuget-oidc) or `CODEGEN_APP_ID` (codegen-app); nuget-oidc needs no stored secret | +| linter-parity | pass | pass | pass | one `.markdownlint-cli2.jsonc` + `cspell.json` drive editor + CLI + CI (`validate-task.yml:56-69`); editorconfig/csharpier shared (`.vscode/tasks.json`, `.config/dotnet-tools.json`); CI runs each linter | +| recurring-violations | pass | pass | pass | comments concise; docs ASCII-clean (only non-ASCII is a deliberate warning emoji `Utilities/FileEx.cs:1090` and Unicode test fixtures `UtilitiesTests/ExtensionsTests.cs:231`); `cspell.json:3` sets `"language": "en-US"`; real endings compliant (CRLF docs/workflows, LF `.husky/pre-commit`). See Drift re `.editorconfig` global default | +| readme-structure | drift | drift | drift | sections mostly present and ordered but omits `## Table of Contents` and `## Questions or Issues`, and uses `## Contributing` for the Development-Environment-Setup slot; minor: missing colon `README.md:9`, shields header `<!-- Shields links -->` vs canonical `<!-- Shields -->` `README.md:65` | +| workflow (WORKFLOW.md 5A/5B) | drift | pass | drift | all applicable D-guarantees hold by **outcome**; divergences are structural (see below): continuous per-branch push-publish instead of two-phase scheduled matrix, no `changes` paths-filter, plain `release-asset` name + exact-name download, keys off `github.ref_name` instead of `inputs.branch` | + +## WORKFLOW.md 5A Static Audit (applicable D-guarantees) + +- **D1.2 / D1.5 (validation + aggregator):** PASS - `validate` (unit-test + lint) runs unconditionally `test-pull-request.yml:25-28`; aggregator `needs: [validate, smoke-build]`, requires `success` `:44-60`; the ruleset-bound name is kept in lockstep `:45`. +- **D1.1 / D1.4 (paths-filter):** DRIFT - no `changes` / `dorny/paths-filter` job; `smoke-build` runs on every push including docs/workflow-only `test-pull-request.yml:32-40`. Deliberate over-build; the "target slips unbuilt" failure mode cannot occur with a single always-built target. +- **D1.3 (smoke uploads/pushes nothing):** PASS - every push/upload gated `if: inputs.publish && !inputs.smoke` `build-release-task.yml:132,140,150,163`. +- **D2.1 / D2.2 (validate-at-entry):** PASS - `validate-release` asserts branch<->classification both directions, strips `+buildmetadata`, skips on smoke `build-release-task.yml:65-94`; downstream `needs:` it. +- **D2.3 (dispatch guard):** DRIFT - the publish job `if:` silently skips a dispatch from a non-main/develop ref `publish-release.yml:47` (template fails fast with `::error::`; skip is safe but non-canonical). +- **D3.1 / D3.2 (classification):** PASS by model - NBGV runs once on the triggering ref `build-release-task.yml:33-61`; config keys off `github.ref_name` `:120,213`, correct here because there is no cross-branch matrix building the other leg from a main-ref run. `version.json:4-6` `publicReleaseRefSpec ^refs/heads/main$`. +- **D3.4 (registry version per classification):** PASS - build injects `-property:PackageVersion=SemVer2` `build-release-task.yml:127`; NuGet.org derives prerelease from the `-g<sha>` suffix. +- **D4.1 (two-phase):** DRIFT - no schedule, no both-branch matrix; publishes on every push to main/develop touching shipped paths `publish-release.yml:16-24`. This is WORKFLOW.md's opt-in continuous-release mode used as the default (no `PUBLISH_ON_MERGE` variable exists). +- **D4.2 / D4.3 / D4.4 (release):** PASS - `target_commitish=GitCommitId` `:212`, `prerelease = ref_name != 'main'` `:213`, create gated `exists == false || workflow_dispatch` `:207`, `--skip-duplicate` no-op re-push `:146`. +- **D5 (cleanup):** PASS - asset delete at the consumer under the same gate as create, `continue-on-error`, filtered by name (not a blanket `.artifacts[].id`) `build-release-task.yml:223-238`; `retention-days: 1` on the one upload `:168`. +- **D6.1 / D6.3 (seam):** DRIFT - single asset named `release-asset` (not `release-asset-<branch>-<target>`) `:166`, downloaded by exact `name:` (not `pattern:` / `merge-multiple:`) `:184-188`. Works for one target; forks the canonical verbatim handoff. +- **D7.1 / D7.2 (concurrency/permissions):** PASS - publisher group ref-independent + `cancel-in-progress: false` `publish-release.yml:28-30`; reusable jobs inherit, caller grants `id-token` / `contents` / `actions: write` at the one entry point `:50-53`. +- **D9.1 (SHA-pinning):** PASS - all actions SHA-pinned with version comments; `dotnet/nbgv@master` is the sanctioned lagging-tag exception `build-release-task.yml:61`. +- **Wrapper / PyPI / Docker / console addenda:** N/A. + +## WORKFLOW.md 5B Trace (applicable scenarios) + +- **S1** (PR touches library): validate + smoke run, no push/upload, validate-release skipped (smoke), aggregator success, version prerelease. PASS. +- **S2 / S3** (docs-only / workflow-only PR): smoke-build **runs** (no paths-filter) instead of skipping; aggregator success - safe but over-builds. DRIFT. +- **S4** (PR base = main): smoke prerelease, validate-release skipped (smoke), promotion not blocked. PASS. +- **S5** (push not touching shipped paths): publish-release not triggered. PASS. +- **S6** (push to develop touching `Utilities/**`): publishes a develop **prerelease** by default, not opt-in. DRIFT (continuous release). +- **S7** (both-branch matrix publish): N/A - no schedule/matrix; a dispatch publishes only its own branch. +- **S8** (dispatch from a non-publishable ref): publish job skipped (template fails fast). DRIFT. +- **S9** (re-run, version unchanged): release-create + asset-delete skipped, NuGet `--skip-duplicate` no-op. PASS. +- **S10** (branch/version disagree): validate-release fails loud. PASS. +- **S11** (wrapper bump): N/A. + +## Defects (most severe first) + +None. No applicable check fails both letter and intent. + +## Drift Findings + +1. **Release model - continuous per-branch push-publish, not two-phase.** `publish-release.yml:16-24` triggers publish on every push to main/develop that touches shipped paths, with no weekly schedule and no both-branch matrix - WORKFLOW.md's opt-in continuous-release mode used as the default (no `PUBLISH_ON_MERGE` variable). Outcomes are correct (main -> stable, develop -> prerelease); the divergence is structural. `publish-release.yml:16-30`. +2. **Seam handoff not canonical.** Single artifact `release-asset` with exact-name download instead of `release-asset-<branch>-<target>` + `pattern:` / `merge-multiple:`; forks the verbatim `github-release` carry and will not extend to a second target without rework. `build-release-task.yml:166,184-188`. +3. **`.editorconfig` lacks the canonical global line-ending default.** `[*]` sets no `end_of_line`; CRLF is re-declared per file-type - the older per-extension form AGENTS.md warns against; any uncovered file type gets no CRLF default. Canonical form is `[*] end_of_line = crlf` + LF pins. `.editorconfig:1-22` (no `[*]` default), `:23-63`. (`.gitattributes:6,11-15` still enforces via `* -text` + LF pins, so real files are compliant.) +4. **Stale hardcoded package versions in the csproj.** `Utilities/Utilities.csproj:17-19` hardcodes `<Version>1.1.1.1</Version>`, `<FileVersion>1.1.1.1</FileVersion>`, `<AssemblyVersion>1.1.1.0</AssemblyVersion>`, contradicting the `version.json:3` floor `3.6`. CI overrides these via `-property:Version=SemVer2` (`build-release-task.yml:123-127`), so published packages are correct, but a local `dotnet pack` produces a wrong `1.1.1.1` package (no Nerdbank.GitVersioning PackageReference; NBGV is workflow-only). +5. **Ruleset naming matches the fleet canonical (not a drift).** The required-status-check `context` and the aggregator job `name:` are both `Check pull request workflow status job` (`test-pull-request.yml:45`) - the `<name> job` convention now adopted template-wide. This report predated that adoption; the naming is canonical, not a deviation. +6. **repo-config filenames off-baseline.** Committed as `repo-config/ruleset-develop.json` / `ruleset-main.json` (baseline expects `develop.json` / `main.json` per `spec/files.json`); the committed files also omit `bypass_actors`, though the **live** rulesets do carry `RepositoryRole 5 always` (matching template). +7. **README omissions.** No `## Table of Contents` heading, no `## Questions or Issues`; `## Contributing` occupies the Development-Environment-Setup slot; `README.md:9` missing colon; shields group header `<!-- Shields links -->` vs canonical `<!-- Shields -->`. +8. **develop stale.** See Develop Drift (28 behind, 0 ahead). + +## Proposed Registry / Spec Updates + +- Registry `Utilities` types `["csharp","nuget"]` and publish `nuget` via `oidc` are accurate; no change. Consider advancing `status` beyond `cataloged` once the drift findings are addressed. +- **Spec signal (candidate machine checks):** the required-status-check context-name divergence (`...status job`) and the per-extension `.editorconfig` shape may recur across derived repos. If several share them, the fix belongs in a spec/lint check - assert the ruleset `context` equals the workflow aggregator `name:`, and assert a global `[*] end_of_line` default exists - rather than per-repo notes. diff --git a/reports/vscode-server-dotnetcore/audit.md b/reports/vscode-server-dotnetcore/audit.md index 1d1f66be..68349ac7 100644 --- a/reports/vscode-server-dotnetcore/audit.md +++ b/reports/vscode-server-dotnetcore/audit.md @@ -1,97 +1,97 @@ -# Audit: VSCode-Server-DotNetCore - -- **Audited branch:** main (`80067f91167f2b56f223946397b69e41b70be834`) -- **Types:** docker (from registry) -- **Verdict:** operational -- **Date:** 2026-07-04 - -## Develop Drift - -`develop` vs `main`: ahead 1, behind 19 (`gh api repos/ptr727/VSCode-Server-DotNetCore/compare/main...develop` -> `status: diverged, ahead_by: 1, behind_by: 19`). **Diverged - a drift finding.** `develop` carries 1 commit `main` lacks and is 19 behind. Under the forward-only model (no `main -> develop` back-merge) the `main`-only CI/CD rework and Dependabot bumps merged straight to `main` have not reached `develop`. The publisher has no push trigger (schedule is `main`-only, `publish-release.yml:12-16`) and `develop` publishes only via manual dispatch, so the divergence has no release impact; still worth reconciling. - -## Dimensions - -| Dimension | Letter | Intent | Verdict | Evidence (file:line) | -| --- | --- | --- | --- | --- | -| csharp | - | - | N/A | no `*.csproj`/`*.slnx`/`*.cs` in tree (`git ls-files`); the image installs the .NET SDK but ships no in-repo source. `.editorconfig:55-222` retains the shared `[*.cs]`+ReSharper block as inert governance | -| nuget | - | - | N/A | no `dotnet nuget push` workflow | -| pypi | - | - | N/A | no Python project / `gh-action-pypi-publish` | -| python | - | - | N/A | no `pyproject.toml`/`setup.py` | -| console | - | - | N/A | no executable build task | -| docker | pass | pass | pass | multi-arch `linux/amd64,linux/arm64` (`build-docker-task.yml:54`); registry layer cache `buildcache-<branch>` (never `type=gha`) `:81-84` (`cache-from` reads both branches, `cache-to` writes only-branch + only-on-push); trimmed `Docker/README.md` published via `peter-evans/dockerhub-description`, main-only (`publish-docker-readme-task.yml:27-34`); image always re-pushes on `inputs.push` (`dockerhub && !smoke`) independent of the release-create skip (`build-release-task.yml:64,118`); plain docker repo tags off `:SemVer2` (`build-docker-task.yml:77`), correct - not a wrapper. Static `DOCKER_HUB_*` secret (expected for docker, not OIDC) | -| branch-model | drift | pass | drift | both branches protected (live rulesets `enforcement: active`); normalized diff of committed `repo-config/ruleset-{develop,main}.json` vs live matches on rules/conditions **except** the committed files omit `bypass_actors` (live carries `RepositoryRole 5 always`) and `required_reviewers: []` inside the `pull_request` params. Required-check `context` `Check pull request workflow status job` (`ruleset-main.json:32`) matches the repo's aggregator `name:` (`test-pull-request.yml:48`) - fleet canonical, not drift. Off-baseline filenames - see Drift | -| repo-setup | pass | pass | pass | actions **and** dependabot stores both hold `DOCKER_HUB_USERNAME`, `DOCKER_HUB_ACCESS_TOKEN` (docker-hub) and `CODEGEN_APP_CLIENT_ID`, `CODEGEN_APP_PRIVATE_KEY` (merge-bot App, `merge-bot-pull-request.yml:42-43`); no forbidden `NUGET_API_KEY`/`CODEGEN_APP_ID`; no stale `DOCKER_HUB_PASSWORD` (cleaner than the ESPHome/PlexCleaner stale-secret cases) | -| linter-parity | pass | pass | pass | one `.markdownlint-cli2.jsonc` + `cspell.json` drive editor + CLI + CI (`validate-task.yml:29-45`); CI runs markdownlint `:29-32`, cspell `:35-41`, actionlint `:44-45`; no C#/Python configs needed (docker-only) | -| recurring-violations | drift | pass | drift | comments concise; docs ASCII-clean (em-dash/smart-quote grep -> none); `cspell.json:3` `"language": "en-US"`. **Letter miss (eol):** `.editorconfig` lacks the global `[*] end_of_line = crlf` default (`:17-22`) **and** `build-docker-task.yml` is committed with **mixed** endings (4 LF lines against its own `[*.{yml,yaml}] crlf` rule) plus a trailing space after a Dockerfile line-continuation. See Drift #1/#2/#3 | -| readme-structure | drift | pass | drift | heavily reshaped from `spec/readme-structure.md`: `## License` is the **first** section (`README.md:6`, canonical last), no `## Build and Distribution` parent, no `## Table of Contents`, no `Releases`/`Use Cases`/`Questions or Issues` sections, headings are flat `## Build Status`/`## Container Images`/`## Release History`/`## Usage` (`:10,16,31,38`), all links are **inline** (not reference-style). Distribution/status/tags/usage content present but non-canonical. `Docker/README.md` is the intentionally-trimmed Hub overview, not measured against the project-README spec | -| workflow (WORKFLOW.md 5A/5B) | drift | pass | drift | all applicable D-guarantees hold by **outcome**; divergences are structural (see 5A/5B): single-branch-per-run publisher, no `changes` paths-filter, NBGV without `IGNORE_GITHUB_REF`, dispatch guard skips instead of failing fast, one-directional embedded release gate, bespoke release task attaching repo files directly without `expect_release_assets` | - -## WORKFLOW.md 5A Static Audit (applicable D-guarantees) - -- **D1.1 / D1.4 (paths-filter):** DRIFT - no `changes`/`dorny/paths-filter` job; `validate` and `smoke-build` run on **every** push (`test-pull-request.yml:25-42`, deliberate per header `:1-10`). Over-builds; with a single always-built target the "changed target slips unbuilt" failure cannot occur. -- **D1.2 (validation always runs):** PASS - `validate` (the lint gate, this docker-only repo's replacement for `unit-test`) runs unconditionally `if: !github.event.deleted` (`test-pull-request.yml:25-28`); the aggregator `needs:` it `:50`. -- **D1.3 (smoke uploads/pushes nothing):** PASS - the smoke caller passes `github: false`, `dockerhub: false`, `smoke: true` (`test-pull-request.yml:38-42`); the Docker push is gated `inputs.dockerhub && !inputs.smoke` (`build-release-task.yml:64`), github-release `inputs.github && !inputs.smoke` `:69`. No `upload-artifact` exists (Docker pushes to the registry; the release attaches repo files directly). -- **D1.5 (one aggregator):** PASS - `check-workflow-status`, `needs: [validate, smoke-build]`, `always() && !github.event.deleted`, fails on any non-`success` (`test-pull-request.yml:47-63`). Name is the ruleset-bound context `:48`. -- **D2.1 / D2.2 (validate-at-entry):** DRIFT (letter) / PASS (intent) - the release gate is the `Verify public release version step` **inside** github-release (`build-release-task.yml:83-93`), not an upfront entry job; it checks **one direction only** (main-not-prerelease), is `inputs.branch == 'main'`-guarded, and only fires inside the `!smoke` github-release. Safe because `version.json:4-6` makes `main` the sole public ref and the publisher builds one branch per run. -- **D2.3 (dispatch guard):** DRIFT - the publish job `if: github.ref_name == 'main' || github.ref_name == 'develop'` (`publish-release.yml:33`) **silently skips** a dispatch from any other ref rather than failing fast with `::error::`. -- **D2.4 (paired/XOR inputs):** N/A - the docker-readme task takes only `branch` and pushes a single `repository:` (`publish-docker-readme-task.yml:5-10,33`); no `repositories` XOR `manifest` combination to validate. -- **D3.1 (version from checked-out branch):** DRIFT (letter) / PASS (intent) - NBGV runs once in `get-version` on `inputs.ref` (`get-version-task.yml:47-57`); **no** `IGNORE_GITHUB_REF=true`. Correct because the publisher builds one branch per run with `github.ref` aligned; a future both-branch matrix would misclassify. -- **D3.2 (default = public, others = prerelease):** PASS - `version.json:4-6` `publicReleaseRefSpec ^refs/heads/main$`; gate literal `inputs.branch == 'main'` `build-release-task.yml:84`; `prerelease: inputs.branch != 'main'` `:124`; image moving tag `main => latest`, else `develop` `build-docker-task.yml:76`. -- **D3.3 (floor + git height):** PASS - `version.json:3` `version "1.1"`; NBGV appends the git height. Retained though the repo has no compiled code (owns the release tag/`GitCommitId`). -- **D3.4 (registry version per classification):** PASS - every published image carries the `:SemVer2` tag (`build-docker-task.yml:77`) plus the moving `latest`/`develop` `:76`. -- **D3.5 (wrapper external version):** N/A - **plain docker repo**, not an upstream wrapper: no `upstream-version.json`, no tracker; the leaf correctly tags off `SemVer2`. The base image `lscr.io/linuxserver/code-server:latest` (`Dockerfile:26`) is `:latest`, refreshed by the weekly rebuild, not a committed pin. -- **D4.1 (two-phase / both branches):** DRIFT - two-phase holds (merges never publish; the publisher runs only on `schedule` + `workflow_dispatch`, no push trigger, `publish-release.yml:12-16`), but it builds **only** the trigger branch `:39-40`, not both via a matrix. The weekly schedule rebuilds `main` only; `develop` only via manual dispatch. Outcomes correct; structural divergence. -- **D4.2 (tag the built commit):** PASS - `target_commitish: needs.get-version.outputs.GitCommitId` `build-release-task.yml:123`; github-release checks out the same commit `:79`. -- **D4.3 (release contents):** DRIFT (letter) / PASS (intent) - tag + `generate_release_notes` + `LICENSE` + `README.md` with `fail_on_unmatched_files: true` (`build-release-task.yml:121-128`); `prerelease = branch != main` `:124`. No `expect_release_assets` input - the bespoke release task attaches the two repo files directly (both exist, so the gate passes) rather than the template's `expect_release_assets: false` + `release-asset-*` mechanism. Equivalent tag+files outcome; forks the mechanism (differs from ESPHome, which omitted `fail_on_unmatched_files`). -- **D4.4 (no-op republish):** PASS - release-create gated `exists == false || workflow_dispatch` `build-release-task.yml:118`; the existence check `:95-112` skips a duplicate on a weekly re-run; Docker still re-pushes within the same run. -- **D5 (cleanup):** N/A - no `upload-artifact` / cross-job transfer artifact anywhere (Docker pushes to the registry; the release attaches `LICENSE`/`README.md`, not produced assets). -- **D6.1 / D6.2 / D6.3 (seam):** PASS (mostly N/A) - no `release-asset-*` artifacts, so no `pattern:`/`artifact-ids:` handoff to police; branch-derived config/tags key off `inputs.branch` (`build-docker-task.yml:76-77`, `build-release-task.yml:84,124`), never `github.ref_name`. -- **D7.1 / D7.2 (concurrency / permissions):** PASS - publisher group `${{ github.workflow }}` ref-independent + `cancel-in-progress: false` (`publish-release.yml:20-22`); the publish caller grants `contents: write` at the one entry point `:36-37`, docker-readme `contents: read`; the merge-bot jobs declare least-privilege `permissions:`; Docker uses the static secret (no OIDC). -- **D7.3 / D7.4 (booleans / optional-dep chaining):** PASS - `build-docker` uses `!cancelled() && get-version == 'success' && (validate == 'success' || 'skipped')` `build-release-task.yml:54`; booleans are `workflow_call`-only here. -- **D8.1 (merge-bot):** PASS - auto-merge on `opened`/`reopened` (`merge-bot-pull-request.yml:28-31`), method by base ref (`develop => --squash`, `main => --merge`) `:48-56`; disable on maintainer `synchronize` `:66-70`; concurrency keyed on the PR number `:17-19`; App token so the merge fires downstream workflows `:38-43`. -- **D8.2 (Dependabot):** PASS - `github-actions` on both `main` and `develop`, daily, grouped (`dependabot.yml:6-27`). -- **D8.3 (upstream tracker):** N/A - not a wrapper. -- **D9.1 (SHA-pinning):** PASS - all actions SHA-pinned with version comments; `dotnet/nbgv@master` is the sanctioned lagging-tag exception (`get-version-task.yml:53-57`). -- **D9.4 (docker cache):** PASS - registry `buildcache-<branch>`, `cache-to` writes only the built branch and only on push, `cache-from` reads both (`build-docker-task.yml:81-84`). -- **D9.5 (line endings):** DRIFT - see Drift #1 (`build-docker-task.yml` committed with mixed endings against `.editorconfig`). -- **NuGet / PyPI / console 5A addenda:** N/A. - -## WORKFLOW.md 5B Trace (applicable scenarios) - -- **S1** (PR touches the Docker target): `validate` (lint) + `smoke-build` (amd64-only, no push) run; no push/upload; github-release skipped (smoke); aggregator success; version prerelease. PASS. -- **S2 / S3** (docs-only / workflow-only PR): `smoke-build` **runs** (no paths-filter) instead of skipping; aggregator success - safe but over-builds. DRIFT. -- **S4** (PR base = main): smoke prerelease; the main-only verify step is inside the `!smoke` github-release so it does not fire; promotion not blocked. PASS. -- **S5 / S6** (push, opt-in un/set): N/A - the publisher has **no** push trigger (`publish-release.yml:12-16`), so no push ever publishes (stronger than the opt-in-variable model). -- **S7** (scheduled/dispatched publish): builds **only** the trigger branch. Schedule -> `main` -> stable + `latest` + `:SemVer2`, Docker Hub overview refreshed; dispatch from `develop` -> prerelease + `develop` tag, readme skipped. DRIFT (single-branch); outcomes correct. -- **S8** (dispatch from a non-publishable ref): publish job **skipped** (`publish-release.yml:33`; template fails fast). DRIFT. -- **S9** (re-run, version unchanged): release-create skipped (tag exists, non-dispatch); Docker still re-pushes; no duplicate release. PASS. -- **S10** (branch/version disagree): the main-only verify step fails loud if `main` carries a prerelease suffix (`build-release-task.yml:90-93`). PASS. -- **S11** (wrapper bump): N/A - not a wrapper. - -## Defects (most severe first) - -None. No applicable check fails both letter and intent. - -## Drift Findings - -1. **`build-docker-task.yml` is committed with mixed line endings, violating the repo's own `.editorconfig` CRLF rule.** 82 of 86 lines are CRLF but 4 are LF-only - lines `52`, `57`, `64`, `70`, which are exactly the `uses: <action>@<sha> # vX` lines Dependabot rewrites - while `.editorconfig:35-37` mandates `[*.{yml,yaml}] end_of_line = crlf` and every other workflow YAML is pure CRLF (`git ls-files --eol` -> `i/mixed` for this file, `i/crlf` for the rest). Nothing caught the split because `.gitattributes:6` uses `* -text` (no renormalization) and `.editorconfig` carries no global `[*] end_of_line` default, so an automated tool rewriting individual lines with LF leaves the file mixed. A **concrete** `recurring.eol` violation, and a sharper form than ESPHome's whole-file-LF case: here the automated `uses:` edits landed LF inside an otherwise-CRLF file. No runtime impact (Actions parses either), but a genuine hygiene miss and diff-churn risk. -2. **`.editorconfig` lacks the canonical global line-ending default.** The `[*]` block (`.editorconfig:17-22`) sets charset/indent/trim but **no** `end_of_line`; CRLF is re-declared per file-type (`:26,31,36,41,53`) with LF pins for `*.sh` and Dockerfiles (`:45,49`) - the older per-extension form the strengthened `recurring.eol` flags. Template canonical is `[*] end_of_line = crlf` + LF pins. **(Recurs with Utilities / PlexCleaner / LanguageTags / ESPHome-NonRoot - five repos now, and here the missing default enabled finding #1.)** -3. **Dockerfile line-continuation carries a trailing space.** `Dockerfile:76` ends with a backslash line-continuation followed by a trailing space (`cat -A` shows `dotnet-install.sh \ $`), which also violates `.editorconfig:22` `trim_trailing_whitespace = true` (no Dockerfile override relaxes it). BuildKit tolerates it (the image publishes), but it is a latent line-continuation hazard and a trailing-whitespace lint miss with no CI guard. -4. **Publisher builds one branch per run, not a both-branch matrix.** `publish-release.yml:12-16,39-40` publishes only the trigger branch; the weekly schedule rebuilds `main` only, `develop` only on manual dispatch. WORKFLOW.md D4.1's model schedules both via a matrix. Outcomes correct; `develop` gets no scheduled refresh. **(Recurs with PlexCleaner / ESPHome-NonRoot.)** -5. **No `changes` paths-filter; every push smoke-builds.** `test-pull-request.yml:25-42` runs `validate` + `smoke-build` on every push including docs-only and workflow-only. **(Recurs across the fleet.)** -6. **Committed rulesets omit fields the live rulesets carry.** `repo-config/ruleset-{develop,main}.json` omit top-level `bypass_actors` (live carries `RepositoryRole 5 always`) and `required_reviewers: []` inside the `pull_request` params. Enforcement intact; context matches. **(Recurs with LanguageTags / ESPHome-NonRoot.)** -7. **repo-config filenames off-baseline.** Committed as `repo-config/ruleset-{develop,main}.json`; `spec/files.json:15-16` expects `repo-config/develop.json` / `main.json`. **(Recurs with Utilities / PlexCleaner / LanguageTags / ESPHome-NonRoot.)** -8. **NBGV without `IGNORE_GITHUB_REF`; release gate one-directional and embedded.** `get-version-task.yml:47-57` runs NBGV with no `IGNORE_GITHUB_REF=true`; `build-release-task.yml:83-93` verifies only that `main` is not prerelease, inside github-release. Correct only because one branch is built per run. **(Recurs with PlexCleaner / LanguageTags / ESPHome-NonRoot.)** -9. **Dispatch guard skips instead of failing fast.** `publish-release.yml:33` silently no-ops a dispatch from a non-`main`/`develop` ref; WORKFLOW.md D2.3 wants a fail-fast `::error::`. **(Recurs across the fleet.)** -10. **Docker-only release attaches repo files without the `expect_release_assets` mechanism.** The bespoke `build-release-task.yml:117-128` lists `LICENSE`/`README.md` directly with `fail_on_unmatched_files: true` rather than the template's `expect_release_assets: false` + `release-asset-*` pattern download. Equivalent tag+files outcome; forks the mechanism (differs from ESPHome-NonRoot, which reached tag-only by omitting `fail_on_unmatched_files`). -11. **README reshaped from the canonical structure.** `## License` is the first section (`README.md:6`); no `## Build and Distribution` parent, no `## Table of Contents`, no `Releases`/`Use Cases`/`Questions or Issues`; headings are flat `##` siblings; all links inline, not reference-style. Content complete; shape non-canonical - more minimal than the other docker repos. -12. **`HISTORY.md` overstates the publish model.** `HISTORY.md:8` says the weekly run "publishes both `main` ... and `develop`", but the schedule is `main`-only (`publish-release.yml:12-16`) and `develop` publishes only via manual dispatch. Minor doc/behavior mismatch. -13. **`develop` diverged from `main`** (ahead 1, behind 19). See Develop Drift. - -## Proposed Registry / Spec Updates - -- Registry `VSCode-Server-DotNetCore` types `["docker"]`, publish `docker` via `static-secret`, status `cataloged` are accurate; **no** upstream-wrapper type applies (own `Dockerfile`, no `upstream-version.json`). Keep `version.json` + NBGV. Consider advancing `status` once the structural workflow drifts and EOL hygiene are addressed. -- **Spec signal (strongest EOL case yet).** The missing global `[*] end_of_line = crlf` default now recurs across five repos, and here it produced a **mixed-ending** file (four automated `uses:` lines committed LF inside an otherwise-CRLF workflow) because `.gitattributes: * -text` does not renormalize. Strengthens the case for a machine check that asserts (a) a global `[*] end_of_line` default exists and (b) tracked files' committed endings match `.editorconfig` - a **per-line** check would catch this mixed case a whole-file check would miss. -- **Spec gap - merge-bot App secrets.** As with PlexCleaner / ESPHome-NonRoot, `spec/secrets.json` models `CODEGEN_APP_CLIENT_ID`/`CODEGEN_APP_PRIVATE_KEY` only under `codegen-app`, but here they are required by the **merge-bot** on a repo with no codegen. Model a `merge-bot`/`app-signing` mechanism requiring the App secrets. -- **Spec gap - docker-only tag+files release.** This repo reaches a no-`release-asset` release by hand-listing `LICENSE`/`README.md` with `fail_on_unmatched_files: true`, a third distinct shape (vs the template's `expect_release_assets: false` and ESPHome's omit-`fail_on_unmatched`). If docker-only repos keep hand-rolling the release task, the spec should bless one canonical no-asset form. -- **Recurring cross-fleet drifts** (off-baseline `repo-config/ruleset-*.json` filenames, committed rulesets omitting `bypass_actors`/`required_reviewers`, no `changes` paths-filter, skip-not-fail dispatch guard, single-branch publisher, NBGV without `IGNORE_GITHUB_REF`) appear here too. +# Audit: VSCode-Server-DotNetCore + +- **Audited branch:** main (`80067f91167f2b56f223946397b69e41b70be834`) +- **Types:** docker (from registry) +- **Verdict:** operational +- **Date:** 2026-07-04 + +## Develop Drift + +`develop` vs `main`: ahead 1, behind 19 (`gh api repos/ptr727/VSCode-Server-DotNetCore/compare/main...develop` -> `status: diverged, ahead_by: 1, behind_by: 19`). **Diverged - a drift finding.** `develop` carries 1 commit `main` lacks and is 19 behind. Under the forward-only model (no `main -> develop` back-merge) the `main`-only CI/CD rework and Dependabot bumps merged straight to `main` have not reached `develop`. The publisher has no push trigger (schedule is `main`-only, `publish-release.yml:12-16`) and `develop` publishes only via manual dispatch, so the divergence has no release impact; still worth reconciling. + +## Dimensions + +| Dimension | Letter | Intent | Verdict | Evidence (file:line) | +| --- | --- | --- | --- | --- | +| csharp | - | - | N/A | no `*.csproj`/`*.slnx`/`*.cs` in tree (`git ls-files`); the image installs the .NET SDK but ships no in-repo source. `.editorconfig:55-222` retains the shared `[*.cs]`+ReSharper block as inert governance | +| nuget | - | - | N/A | no `dotnet nuget push` workflow | +| pypi | - | - | N/A | no Python project / `gh-action-pypi-publish` | +| python | - | - | N/A | no `pyproject.toml`/`setup.py` | +| console | - | - | N/A | no executable build task | +| docker | pass | pass | pass | multi-arch `linux/amd64,linux/arm64` (`build-docker-task.yml:54`); registry layer cache `buildcache-<branch>` (never `type=gha`) `:81-84` (`cache-from` reads both branches, `cache-to` writes only-branch + only-on-push); trimmed `Docker/README.md` published via `peter-evans/dockerhub-description`, main-only (`publish-docker-readme-task.yml:27-34`); image always re-pushes on `inputs.push` (`dockerhub && !smoke`) independent of the release-create skip (`build-release-task.yml:64,118`); plain docker repo tags off `:SemVer2` (`build-docker-task.yml:77`), correct - not a wrapper. Static `DOCKER_HUB_*` secret (expected for docker, not OIDC) | +| branch-model | drift | pass | drift | both branches protected (live rulesets `enforcement: active`); normalized diff of committed `repo-config/ruleset-{develop,main}.json` vs live matches on rules/conditions **except** the committed files omit `bypass_actors` (live carries `RepositoryRole 5 always`) and `required_reviewers: []` inside the `pull_request` params. Required-check `context` `Check pull request workflow status job` (`ruleset-main.json:32`) matches the repo's aggregator `name:` (`test-pull-request.yml:48`) - fleet canonical, not drift. Off-baseline filenames - see Drift | +| repo-setup | pass | pass | pass | actions **and** dependabot stores both hold `DOCKER_HUB_USERNAME`, `DOCKER_HUB_ACCESS_TOKEN` (docker-hub) and `CODEGEN_APP_CLIENT_ID`, `CODEGEN_APP_PRIVATE_KEY` (merge-bot App, `merge-bot-pull-request.yml:42-43`); no forbidden `NUGET_API_KEY`/`CODEGEN_APP_ID`; no stale `DOCKER_HUB_PASSWORD` (cleaner than the ESPHome/PlexCleaner stale-secret cases) | +| linter-parity | pass | pass | pass | one `.markdownlint-cli2.jsonc` + `cspell.json` drive editor + CLI + CI (`validate-task.yml:29-45`); CI runs markdownlint `:29-32`, cspell `:35-41`, actionlint `:44-45`; no C#/Python configs needed (docker-only) | +| recurring-violations | drift | pass | drift | comments concise; docs ASCII-clean (em-dash/smart-quote grep -> none); `cspell.json:3` `"language": "en-US"`. **Letter miss (eol):** `.editorconfig` lacks the global `[*] end_of_line = crlf` default (`:17-22`) **and** `build-docker-task.yml` is committed with **mixed** endings (4 LF lines against its own `[*.{yml,yaml}] crlf` rule) plus a trailing space after a Dockerfile line-continuation. See Drift #1/#2/#3 | +| readme-structure | drift | pass | drift | heavily reshaped from `spec/readme-structure.md`: `## License` is the **first** section (`README.md:6`, canonical last), no `## Build and Distribution` parent, no `## Table of Contents`, no `Releases`/`Use Cases`/`Questions or Issues` sections, headings are flat `## Build Status`/`## Container Images`/`## Release History`/`## Usage` (`:10,16,31,38`), all links are **inline** (not reference-style). Distribution/status/tags/usage content present but non-canonical. `Docker/README.md` is the intentionally-trimmed Hub overview, not measured against the project-README spec | +| workflow (WORKFLOW.md 5A/5B) | drift | pass | drift | all applicable D-guarantees hold by **outcome**; divergences are structural (see 5A/5B): single-branch-per-run publisher, no `changes` paths-filter, NBGV without `IGNORE_GITHUB_REF`, dispatch guard skips instead of failing fast, one-directional embedded release gate, bespoke release task attaching repo files directly without `expect_release_assets` | + +## WORKFLOW.md 5A Static Audit (applicable D-guarantees) + +- **D1.1 / D1.4 (paths-filter):** DRIFT - no `changes`/`dorny/paths-filter` job; `validate` and `smoke-build` run on **every** push (`test-pull-request.yml:25-42`, deliberate per header `:1-10`). Over-builds; with a single always-built target the "changed target slips unbuilt" failure cannot occur. +- **D1.2 (validation always runs):** PASS - `validate` (the lint gate, this docker-only repo's replacement for `unit-test`) runs unconditionally `if: !github.event.deleted` (`test-pull-request.yml:25-28`); the aggregator `needs:` it `:50`. +- **D1.3 (smoke uploads/pushes nothing):** PASS - the smoke caller passes `github: false`, `dockerhub: false`, `smoke: true` (`test-pull-request.yml:38-42`); the Docker push is gated `inputs.dockerhub && !inputs.smoke` (`build-release-task.yml:64`), github-release `inputs.github && !inputs.smoke` `:69`. No `upload-artifact` exists (Docker pushes to the registry; the release attaches repo files directly). +- **D1.5 (one aggregator):** PASS - `check-workflow-status`, `needs: [validate, smoke-build]`, `always() && !github.event.deleted`, fails on any non-`success` (`test-pull-request.yml:47-63`). Name is the ruleset-bound context `:48`. +- **D2.1 / D2.2 (validate-at-entry):** DRIFT (letter) / PASS (intent) - the release gate is the `Verify public release version step` **inside** github-release (`build-release-task.yml:83-93`), not an upfront entry job; it checks **one direction only** (main-not-prerelease), is `inputs.branch == 'main'`-guarded, and only fires inside the `!smoke` github-release. Safe because `version.json:4-6` makes `main` the sole public ref and the publisher builds one branch per run. +- **D2.3 (dispatch guard):** DRIFT - the publish job `if: github.ref_name == 'main' || github.ref_name == 'develop'` (`publish-release.yml:33`) **silently skips** a dispatch from any other ref rather than failing fast with `::error::`. +- **D2.4 (paired/XOR inputs):** N/A - the docker-readme task takes only `branch` and pushes a single `repository:` (`publish-docker-readme-task.yml:5-10,33`); no `repositories` XOR `manifest` combination to validate. +- **D3.1 (version from checked-out branch):** DRIFT (letter) / PASS (intent) - NBGV runs once in `get-version` on `inputs.ref` (`get-version-task.yml:47-57`); **no** `IGNORE_GITHUB_REF=true`. Correct because the publisher builds one branch per run with `github.ref` aligned; a future both-branch matrix would misclassify. +- **D3.2 (default = public, others = prerelease):** PASS - `version.json:4-6` `publicReleaseRefSpec ^refs/heads/main$`; gate literal `inputs.branch == 'main'` `build-release-task.yml:84`; `prerelease: inputs.branch != 'main'` `:124`; image moving tag `main => latest`, else `develop` `build-docker-task.yml:76`. +- **D3.3 (floor + git height):** PASS - `version.json:3` `version "1.1"`; NBGV appends the git height. Retained though the repo has no compiled code (owns the release tag/`GitCommitId`). +- **D3.4 (registry version per classification):** PASS - every published image carries the `:SemVer2` tag (`build-docker-task.yml:77`) plus the moving `latest`/`develop` `:76`. +- **D3.5 (wrapper external version):** N/A - **plain docker repo**, not an upstream wrapper: no `upstream-version.json`, no tracker; the leaf correctly tags off `SemVer2`. The base image `lscr.io/linuxserver/code-server:latest` (`Dockerfile:26`) is `:latest`, refreshed by the weekly rebuild, not a committed pin. +- **D4.1 (two-phase / both branches):** DRIFT - two-phase holds (merges never publish; the publisher runs only on `schedule` + `workflow_dispatch`, no push trigger, `publish-release.yml:12-16`), but it builds **only** the trigger branch `:39-40`, not both via a matrix. The weekly schedule rebuilds `main` only; `develop` only via manual dispatch. Outcomes correct; structural divergence. +- **D4.2 (tag the built commit):** PASS - `target_commitish: needs.get-version.outputs.GitCommitId` `build-release-task.yml:123`; github-release checks out the same commit `:79`. +- **D4.3 (release contents):** DRIFT (letter) / PASS (intent) - tag + `generate_release_notes` + `LICENSE` + `README.md` with `fail_on_unmatched_files: true` (`build-release-task.yml:121-128`); `prerelease = branch != main` `:124`. No `expect_release_assets` input - the bespoke release task attaches the two repo files directly (both exist, so the gate passes) rather than the template's `expect_release_assets: false` + `release-asset-*` mechanism. Equivalent tag+files outcome; forks the mechanism (differs from ESPHome, which omitted `fail_on_unmatched_files`). +- **D4.4 (no-op republish):** PASS - release-create gated `exists == false || workflow_dispatch` `build-release-task.yml:118`; the existence check `:95-112` skips a duplicate on a weekly re-run; Docker still re-pushes within the same run. +- **D5 (cleanup):** N/A - no `upload-artifact` / cross-job transfer artifact anywhere (Docker pushes to the registry; the release attaches `LICENSE`/`README.md`, not produced assets). +- **D6.1 / D6.2 / D6.3 (seam):** PASS (mostly N/A) - no `release-asset-*` artifacts, so no `pattern:`/`artifact-ids:` handoff to police; branch-derived config/tags key off `inputs.branch` (`build-docker-task.yml:76-77`, `build-release-task.yml:84,124`), never `github.ref_name`. +- **D7.1 / D7.2 (concurrency / permissions):** PASS - publisher group `${{ github.workflow }}` ref-independent + `cancel-in-progress: false` (`publish-release.yml:20-22`); the publish caller grants `contents: write` at the one entry point `:36-37`, docker-readme `contents: read`; the merge-bot jobs declare least-privilege `permissions:`; Docker uses the static secret (no OIDC). +- **D7.3 / D7.4 (booleans / optional-dep chaining):** PASS - `build-docker` uses `!cancelled() && get-version == 'success' && (validate == 'success' || 'skipped')` `build-release-task.yml:54`; booleans are `workflow_call`-only here. +- **D8.1 (merge-bot):** PASS - auto-merge on `opened`/`reopened` (`merge-bot-pull-request.yml:28-31`), method by base ref (`develop => --squash`, `main => --merge`) `:48-56`; disable on maintainer `synchronize` `:66-70`; concurrency keyed on the PR number `:17-19`; App token so the merge fires downstream workflows `:38-43`. +- **D8.2 (Dependabot):** PASS - `github-actions` on both `main` and `develop`, daily, grouped (`dependabot.yml:6-27`). +- **D8.3 (upstream tracker):** N/A - not a wrapper. +- **D9.1 (SHA-pinning):** PASS - all actions SHA-pinned with version comments; `dotnet/nbgv@master` is the sanctioned lagging-tag exception (`get-version-task.yml:53-57`). +- **D9.4 (docker cache):** PASS - registry `buildcache-<branch>`, `cache-to` writes only the built branch and only on push, `cache-from` reads both (`build-docker-task.yml:81-84`). +- **D9.5 (line endings):** DRIFT - see Drift #1 (`build-docker-task.yml` committed with mixed endings against `.editorconfig`). +- **NuGet / PyPI / console 5A addenda:** N/A. + +## WORKFLOW.md 5B Trace (applicable scenarios) + +- **S1** (PR touches the Docker target): `validate` (lint) + `smoke-build` (amd64-only, no push) run; no push/upload; github-release skipped (smoke); aggregator success; version prerelease. PASS. +- **S2 / S3** (docs-only / workflow-only PR): `smoke-build` **runs** (no paths-filter) instead of skipping; aggregator success - safe but over-builds. DRIFT. +- **S4** (PR base = main): smoke prerelease; the main-only verify step is inside the `!smoke` github-release so it does not fire; promotion not blocked. PASS. +- **S5 / S6** (push, opt-in un/set): N/A - the publisher has **no** push trigger (`publish-release.yml:12-16`), so no push ever publishes (stronger than the opt-in-variable model). +- **S7** (scheduled/dispatched publish): builds **only** the trigger branch. Schedule -> `main` -> stable + `latest` + `:SemVer2`, Docker Hub overview refreshed; dispatch from `develop` -> prerelease + `develop` tag, readme skipped. DRIFT (single-branch); outcomes correct. +- **S8** (dispatch from a non-publishable ref): publish job **skipped** (`publish-release.yml:33`; template fails fast). DRIFT. +- **S9** (re-run, version unchanged): release-create skipped (tag exists, non-dispatch); Docker still re-pushes; no duplicate release. PASS. +- **S10** (branch/version disagree): the main-only verify step fails loud if `main` carries a prerelease suffix (`build-release-task.yml:90-93`). PASS. +- **S11** (wrapper bump): N/A - not a wrapper. + +## Defects (most severe first) + +None. No applicable check fails both letter and intent. + +## Drift Findings + +1. **`build-docker-task.yml` is committed with mixed line endings, violating the repo's own `.editorconfig` CRLF rule.** 82 of 86 lines are CRLF but 4 are LF-only - lines `52`, `57`, `64`, `70`, which are exactly the `uses: <action>@<sha> # vX` lines Dependabot rewrites - while `.editorconfig:35-37` mandates `[*.{yml,yaml}] end_of_line = crlf` and every other workflow YAML is pure CRLF (`git ls-files --eol` -> `i/mixed` for this file, `i/crlf` for the rest). Nothing caught the split because `.gitattributes:6` uses `* -text` (no renormalization) and `.editorconfig` carries no global `[*] end_of_line` default, so an automated tool rewriting individual lines with LF leaves the file mixed. A **concrete** `recurring.eol` violation, and a sharper form than ESPHome's whole-file-LF case: here the automated `uses:` edits landed LF inside an otherwise-CRLF file. No runtime impact (Actions parses either), but a genuine hygiene miss and diff-churn risk. +2. **`.editorconfig` lacks the canonical global line-ending default.** The `[*]` block (`.editorconfig:17-22`) sets charset/indent/trim but **no** `end_of_line`; CRLF is re-declared per file-type (`:26,31,36,41,53`) with LF pins for `*.sh` and Dockerfiles (`:45,49`) - the older per-extension form the strengthened `recurring.eol` flags. Template canonical is `[*] end_of_line = crlf` + LF pins. **(Recurs with Utilities / PlexCleaner / LanguageTags / ESPHome-NonRoot - five repos now, and here the missing default enabled finding #1.)** +3. **Dockerfile line-continuation carries a trailing space.** `Dockerfile:76` ends with a backslash line-continuation followed by a trailing space (`cat -A` shows `dotnet-install.sh \ $`), which also violates `.editorconfig:22` `trim_trailing_whitespace = true` (no Dockerfile override relaxes it). BuildKit tolerates it (the image publishes), but it is a latent line-continuation hazard and a trailing-whitespace lint miss with no CI guard. +4. **Publisher builds one branch per run, not a both-branch matrix.** `publish-release.yml:12-16,39-40` publishes only the trigger branch; the weekly schedule rebuilds `main` only, `develop` only on manual dispatch. WORKFLOW.md D4.1's model schedules both via a matrix. Outcomes correct; `develop` gets no scheduled refresh. **(Recurs with PlexCleaner / ESPHome-NonRoot.)** +5. **No `changes` paths-filter; every push smoke-builds.** `test-pull-request.yml:25-42` runs `validate` + `smoke-build` on every push including docs-only and workflow-only. **(Recurs across the fleet.)** +6. **Committed rulesets omit fields the live rulesets carry.** `repo-config/ruleset-{develop,main}.json` omit top-level `bypass_actors` (live carries `RepositoryRole 5 always`) and `required_reviewers: []` inside the `pull_request` params. Enforcement intact; context matches. **(Recurs with LanguageTags / ESPHome-NonRoot.)** +7. **repo-config filenames off-baseline.** Committed as `repo-config/ruleset-{develop,main}.json`; `spec/files.json:15-16` expects `repo-config/develop.json` / `main.json`. **(Recurs with Utilities / PlexCleaner / LanguageTags / ESPHome-NonRoot.)** +8. **NBGV without `IGNORE_GITHUB_REF`; release gate one-directional and embedded.** `get-version-task.yml:47-57` runs NBGV with no `IGNORE_GITHUB_REF=true`; `build-release-task.yml:83-93` verifies only that `main` is not prerelease, inside github-release. Correct only because one branch is built per run. **(Recurs with PlexCleaner / LanguageTags / ESPHome-NonRoot.)** +9. **Dispatch guard skips instead of failing fast.** `publish-release.yml:33` silently no-ops a dispatch from a non-`main`/`develop` ref; WORKFLOW.md D2.3 wants a fail-fast `::error::`. **(Recurs across the fleet.)** +10. **Docker-only release attaches repo files without the `expect_release_assets` mechanism.** The bespoke `build-release-task.yml:117-128` lists `LICENSE`/`README.md` directly with `fail_on_unmatched_files: true` rather than the template's `expect_release_assets: false` + `release-asset-*` pattern download. Equivalent tag+files outcome; forks the mechanism (differs from ESPHome-NonRoot, which reached tag-only by omitting `fail_on_unmatched_files`). +11. **README reshaped from the canonical structure.** `## License` is the first section (`README.md:6`); no `## Build and Distribution` parent, no `## Table of Contents`, no `Releases`/`Use Cases`/`Questions or Issues`; headings are flat `##` siblings; all links inline, not reference-style. Content complete; shape non-canonical - more minimal than the other docker repos. +12. **`HISTORY.md` overstates the publish model.** `HISTORY.md:8` says the weekly run "publishes both `main` ... and `develop`", but the schedule is `main`-only (`publish-release.yml:12-16`) and `develop` publishes only via manual dispatch. Minor doc/behavior mismatch. +13. **`develop` diverged from `main`** (ahead 1, behind 19). See Develop Drift. + +## Proposed Registry / Spec Updates + +- Registry `VSCode-Server-DotNetCore` types `["docker"]`, publish `docker` via `static-secret`, status `cataloged` are accurate; **no** upstream-wrapper type applies (own `Dockerfile`, no `upstream-version.json`). Keep `version.json` + NBGV. Consider advancing `status` once the structural workflow drifts and EOL hygiene are addressed. +- **Spec signal (strongest EOL case yet).** The missing global `[*] end_of_line = crlf` default now recurs across five repos, and here it produced a **mixed-ending** file (four automated `uses:` lines committed LF inside an otherwise-CRLF workflow) because `.gitattributes: * -text` does not renormalize. Strengthens the case for a machine check that asserts (a) a global `[*] end_of_line` default exists and (b) tracked files' committed endings match `.editorconfig` - a **per-line** check would catch this mixed case a whole-file check would miss. +- **Spec gap - merge-bot App secrets.** As with PlexCleaner / ESPHome-NonRoot, `spec/secrets.json` models `CODEGEN_APP_CLIENT_ID`/`CODEGEN_APP_PRIVATE_KEY` only under `codegen-app`, but here they are required by the **merge-bot** on a repo with no codegen. Model a `merge-bot`/`app-signing` mechanism requiring the App secrets. +- **Spec gap - docker-only tag+files release.** This repo reaches a no-`release-asset` release by hand-listing `LICENSE`/`README.md` with `fail_on_unmatched_files: true`, a third distinct shape (vs the template's `expect_release_assets: false` and ESPHome's omit-`fail_on_unmatched`). If docker-only repos keep hand-rolling the release task, the spec should bless one canonical no-asset form. +- **Recurring cross-fleet drifts** (off-baseline `repo-config/ruleset-*.json` filenames, committed rulesets omitting `bypass_actors`/`required_reviewers`, no `changes` paths-filter, skip-not-fail dispatch guard, single-branch publisher, NBGV without `IGNORE_GITHUB_REF`) appear here too. diff --git a/reports/workflow-reuse.md b/reports/workflow-reuse.md index d765ba8b..58a09f9a 100644 --- a/reports/workflow-reuse.md +++ b/reports/workflow-reuse.md @@ -1,161 +1,161 @@ -# Fleet workflow reuse report - -Generated by `python3 spec/workflow_reuse.py --report` at hub `d08fea7` - do not hand-edit. Each row reads a repo's ground-truth branch at generation time and compares it against the hub canonical of the same name after line-ending, action-pin, and job-needs normalization, per [`spec/fidelity-model.md`][fidelity-model] "Normalization". Git dates this file. The target model and the migration phases are in [`docs/reusable-workflows.md`][reusable-workflows]. - -## Fleet Total - -- **108 workflow files, 10,964 lines** across 20 downstream repos, 103 of them named for a hub canonical. No workflow at all in EspDinIoT. -- **4,201 lines (38%) are byte-identical to a hub canonical** after normalization, which is the confirmed duplication. The rest is mostly a per-repo edit of the same canonical rather than independent code. -- **Files reaching a hub reusable workflow or composite action through a pinned `uses:`: 1.** That is the state every carried copy converges to, so this number rises and the two above fall as the migration lands. - -## Per Workflow - -Downstream copies of each hub canonical. A variant is a cluster of copies each at or above 0.85 similarity to the cluster's first member, so the cluster count is how many distinct shapes of one workflow the fleet runs today. Callers are the copies that already reach the hub rather than carrying the job bodies. - -| File | Copies | Lines | Identical to hub | Variants | Callers | -| --- | --- | --- | --- | --- | --- | -| `build-release-task.yml` | 10 | 1,934 | 1,011 | 6 | 0 | -| `merge-bot-pull-request.yml` | 16 | 1,694 | 191 | 8 | 0 | -| `test-pull-request.yml` | 20 | 1,652 | 486 | 13 | 0 | -| `publish-release.yml` | 17 | 1,345 | 539 | 12 | 0 | -| `validate-task.yml` | 13 | 1,289 | 452 | 11 | 1 | -| `build-docker-task.yml` | 5 | 625 | 298 | 4 | 0 | -| `get-version-task.yml` | 8 | 495 | 364 | 4 | 0 | -| `publish-plan-task.yml` | 3 | 252 | 207 | 1 | 0 | -| `build-executable-task.yml` | 2 | 214 | 154 | 1 | 0 | -| `deploy-site-task.yml` | 1 | 191 | 119 | 1 | 0 | -| `run-codegen-pull-request-task.yml` | 2 | 161 | 140 | 1 | 0 | -| `check-upstream-version-task.yml` | 1 | 133 | 109 | 1 | 0 | -| `deploy-site.yml` | 1 | 55 | 41 | 1 | 0 | -| `run-periodic-codegen-pull-request.yml` | 2 | 48 | 40 | 2 | 0 | -| `build-datebadge-task.yml` | 1 | 37 | 27 | 1 | 0 | -| `publish-docker-readme-task.yml` | 1 | 34 | 23 | 1 | 0 | - -### Variant Members - -Each variant names the repos whose copies cluster together, so a hub task's inputs and hooks can be designed against the shapes that exist rather than against the canonical alone. - -- `build-release-task.yml` - - 2: ESPHome-NonRoot, VSCode-Server-DotNetCore - - 1: KiCadLibrary - - 3: LanguageTags, MediaTools, Utilities - - 2: PhotoCleaner, PlexCleaner - - 1: aiopurpleair - - 1: homeassistant-purpleair -- `merge-bot-pull-request.yml` - - 1: Blog - - 6: ESPHome-Config, HomeAssistant-Config, HomeAutomation-Config, PlexCleaner, Utilities, Vantage-Config - - 3: ESPHome-NonRoot, NxWitness, homeassistant-purpleair - - 1: KiCadLibrary - - 1: LanguageTags - - 2: MediaTools, aiopurpleair - - 1: PhotoCleaner - - 1: VSCode-Server-DotNetCore -- `test-pull-request.yml` - - 2: AudioCleaner, Financial-Modeling - - 1: Blog - - 2: DevKitCIoT, HolidayLights - - 1: ESPHome-Config - - 1: ESPHome-NonRoot - - 2: HomeAssistant-Config, Vantage-Config - - 1: HomeAutomation-Config - - 1: KiCadLibrary - - 4: LanguageTags, MediaTools, Utilities, aiopurpleair - - 1: NxWitness - - 1: PhotoCleaner - - 2: PlexCleaner, VSCode-Server-DotNetCore - - 1: homeassistant-purpleair -- `publish-release.yml` - - 1: Blog - - 3: ESPHome-Config, HomeAssistant-Config, Vantage-Config - - 1: ESPHome-NonRoot - - 2: Financial-Modeling, HomeAutomation-Config - - 1: KiCadLibrary - - 2: LanguageTags, MediaTools - - 1: NxWitness - - 2: PhotoCleaner, PlexCleaner - - 1: Utilities - - 1: VSCode-Server-DotNetCore - - 1: aiopurpleair - - 1: homeassistant-purpleair -- `validate-task.yml` - - 3: AudioCleaner, MediaTools, Utilities - - 1: Blog - - 1: ESPHome-NonRoot - - 1: Financial-Modeling - - 1: HomeAutomation-Config - - 1: LanguageTags - - 1: NxWitness - - 1: PhotoCleaner - - 1: PlexCleaner - - 1: VSCode-Server-DotNetCore - - 1: aiopurpleair -- `build-docker-task.yml` - - 1: ESPHome-NonRoot - - 1: NxWitness - - 2: PhotoCleaner, PlexCleaner - - 1: VSCode-Server-DotNetCore -- `get-version-task.yml` - - 5: ESPHome-NonRoot, NxWitness, PhotoCleaner, PlexCleaner, VSCode-Server-DotNetCore - - 1: KiCadLibrary - - 1: aiopurpleair - - 1: homeassistant-purpleair -- `publish-plan-task.yml` - - 3: ESPHome-NonRoot, NxWitness, Utilities -- `build-executable-task.yml` - - 2: PhotoCleaner, PlexCleaner -- `deploy-site-task.yml` - - 1: Blog -- `run-codegen-pull-request-task.yml` - - 2: LanguageTags, NxWitness -- `check-upstream-version-task.yml` - - 1: ESPHome-NonRoot -- `deploy-site.yml` - - 1: Blog -- `run-periodic-codegen-pull-request.yml` - - 1: LanguageTags - - 1: NxWitness -- `build-datebadge-task.yml` - - 1: KiCadLibrary -- `publish-docker-readme-task.yml` - - 1: VSCode-Server-DotNetCore - -## Per Repo - -| Repo | Files | Lines | Identical to hub | Callers | Repo-local files | -| --- | --- | --- | --- | --- | --- | -| AudioCleaner | 2 | 134 | 55 | 0 | - | -| Blog | 6 | 560 | 317 | 0 | - | -| DevKitCIoT | 1 | 58 | 25 | 0 | - | -| ESPHome-Config | 3 | 450 | 90 | 0 | - | -| ESPHome-NonRoot | 11 | 1,154 | 463 | 0 | `check-upstream-dependency.yml`, `check-upstream-version.yml` | -| Financial-Modeling | 3 | 233 | 124 | 0 | - | -| HolidayLights | 1 | 53 | 25 | 0 | - | -| HomeAssistant-Config | 3 | 229 | 88 | 0 | - | -| HomeAutomation-Config | 4 | 248 | 144 | 0 | - | -| KiCadLibrary | 6 | 772 | 221 | 0 | - | -| LanguageTags | 7 | 724 | 265 | 0 | - | -| MediaTools | 5 | 530 | 173 | 0 | - | -| NxWitness | 10 | 1,102 | 363 | 0 | `build-base-images-task.yml` | -| PhotoCleaner | 8 | 827 | 457 | 1 | - | -| PlexCleaner | 8 | 805 | 455 | 0 | - | -| Utilities | 6 | 621 | 242 | 0 | - | -| VSCode-Server-DotNetCore | 8 | 563 | 297 | 0 | - | -| Vantage-Config | 3 | 229 | 88 | 0 | - | -| aiopurpleair | 6 | 611 | 198 | 0 | - | -| homeassistant-purpleair | 7 | 1,061 | 111 | 0 | `check-ha-version.yml`, `test-release-task.yml` | - -## Repo-Local Workflows - -A workflow no hub canonical names. Each is either genuinely repo-specific, and stays, or a candidate for a hub task with a hook, and the design doc lists which. - -- **ESPHome-NonRoot** `check-upstream-dependency.yml` (111 lines) -- **ESPHome-NonRoot** `check-upstream-version.yml` (41 lines) -- **NxWitness** `build-base-images-task.yml` (89 lines) -- **homeassistant-purpleair** `check-ha-version.yml` (316 lines) -- **homeassistant-purpleair** `test-release-task.yml` (248 lines) - -<!-- Repo --> - -[fidelity-model]: ../spec/fidelity-model.md -[reusable-workflows]: ../docs/reusable-workflows.md +# Fleet workflow reuse report + +Generated by `python3 spec/workflow_reuse.py --report` at hub `d08fea7` - do not hand-edit. Each row reads a repo's ground-truth branch at generation time and compares it against the hub canonical of the same name after line-ending, action-pin, and job-needs normalization, per [`spec/fidelity-model.md`][fidelity-model] "Normalization". Git dates this file. The target model and the migration phases are in [`docs/reusable-workflows.md`][reusable-workflows]. + +## Fleet Total + +- **108 workflow files, 10,964 lines** across 20 downstream repos, 103 of them named for a hub canonical. No workflow at all in EspDinIoT. +- **4,201 lines (38%) are byte-identical to a hub canonical** after normalization, which is the confirmed duplication. The rest is mostly a per-repo edit of the same canonical rather than independent code. +- **Files reaching a hub reusable workflow or composite action through a pinned `uses:`: 1.** That is the state every carried copy converges to, so this number rises and the two above fall as the migration lands. + +## Per Workflow + +Downstream copies of each hub canonical. A variant is a cluster of copies each at or above 0.85 similarity to the cluster's first member, so the cluster count is how many distinct shapes of one workflow the fleet runs today. Callers are the copies that already reach the hub rather than carrying the job bodies. + +| File | Copies | Lines | Identical to hub | Variants | Callers | +| --- | --- | --- | --- | --- | --- | +| `build-release-task.yml` | 10 | 1,934 | 1,011 | 6 | 0 | +| `merge-bot-pull-request.yml` | 16 | 1,694 | 191 | 8 | 0 | +| `test-pull-request.yml` | 20 | 1,652 | 486 | 13 | 0 | +| `publish-release.yml` | 17 | 1,345 | 539 | 12 | 0 | +| `validate-task.yml` | 13 | 1,289 | 452 | 11 | 1 | +| `build-docker-task.yml` | 5 | 625 | 298 | 4 | 0 | +| `get-version-task.yml` | 8 | 495 | 364 | 4 | 0 | +| `publish-plan-task.yml` | 3 | 252 | 207 | 1 | 0 | +| `build-executable-task.yml` | 2 | 214 | 154 | 1 | 0 | +| `deploy-site-task.yml` | 1 | 191 | 119 | 1 | 0 | +| `run-codegen-pull-request-task.yml` | 2 | 161 | 140 | 1 | 0 | +| `check-upstream-version-task.yml` | 1 | 133 | 109 | 1 | 0 | +| `deploy-site.yml` | 1 | 55 | 41 | 1 | 0 | +| `run-periodic-codegen-pull-request.yml` | 2 | 48 | 40 | 2 | 0 | +| `build-datebadge-task.yml` | 1 | 37 | 27 | 1 | 0 | +| `publish-docker-readme-task.yml` | 1 | 34 | 23 | 1 | 0 | + +### Variant Members + +Each variant names the repos whose copies cluster together, so a hub task's inputs and hooks can be designed against the shapes that exist rather than against the canonical alone. + +- `build-release-task.yml` + - 2: ESPHome-NonRoot, VSCode-Server-DotNetCore + - 1: KiCadLibrary + - 3: LanguageTags, MediaTools, Utilities + - 2: PhotoCleaner, PlexCleaner + - 1: aiopurpleair + - 1: homeassistant-purpleair +- `merge-bot-pull-request.yml` + - 1: Blog + - 6: ESPHome-Config, HomeAssistant-Config, HomeAutomation-Config, PlexCleaner, Utilities, Vantage-Config + - 3: ESPHome-NonRoot, NxWitness, homeassistant-purpleair + - 1: KiCadLibrary + - 1: LanguageTags + - 2: MediaTools, aiopurpleair + - 1: PhotoCleaner + - 1: VSCode-Server-DotNetCore +- `test-pull-request.yml` + - 2: AudioCleaner, Financial-Modeling + - 1: Blog + - 2: DevKitCIoT, HolidayLights + - 1: ESPHome-Config + - 1: ESPHome-NonRoot + - 2: HomeAssistant-Config, Vantage-Config + - 1: HomeAutomation-Config + - 1: KiCadLibrary + - 4: LanguageTags, MediaTools, Utilities, aiopurpleair + - 1: NxWitness + - 1: PhotoCleaner + - 2: PlexCleaner, VSCode-Server-DotNetCore + - 1: homeassistant-purpleair +- `publish-release.yml` + - 1: Blog + - 3: ESPHome-Config, HomeAssistant-Config, Vantage-Config + - 1: ESPHome-NonRoot + - 2: Financial-Modeling, HomeAutomation-Config + - 1: KiCadLibrary + - 2: LanguageTags, MediaTools + - 1: NxWitness + - 2: PhotoCleaner, PlexCleaner + - 1: Utilities + - 1: VSCode-Server-DotNetCore + - 1: aiopurpleair + - 1: homeassistant-purpleair +- `validate-task.yml` + - 3: AudioCleaner, MediaTools, Utilities + - 1: Blog + - 1: ESPHome-NonRoot + - 1: Financial-Modeling + - 1: HomeAutomation-Config + - 1: LanguageTags + - 1: NxWitness + - 1: PhotoCleaner + - 1: PlexCleaner + - 1: VSCode-Server-DotNetCore + - 1: aiopurpleair +- `build-docker-task.yml` + - 1: ESPHome-NonRoot + - 1: NxWitness + - 2: PhotoCleaner, PlexCleaner + - 1: VSCode-Server-DotNetCore +- `get-version-task.yml` + - 5: ESPHome-NonRoot, NxWitness, PhotoCleaner, PlexCleaner, VSCode-Server-DotNetCore + - 1: KiCadLibrary + - 1: aiopurpleair + - 1: homeassistant-purpleair +- `publish-plan-task.yml` + - 3: ESPHome-NonRoot, NxWitness, Utilities +- `build-executable-task.yml` + - 2: PhotoCleaner, PlexCleaner +- `deploy-site-task.yml` + - 1: Blog +- `run-codegen-pull-request-task.yml` + - 2: LanguageTags, NxWitness +- `check-upstream-version-task.yml` + - 1: ESPHome-NonRoot +- `deploy-site.yml` + - 1: Blog +- `run-periodic-codegen-pull-request.yml` + - 1: LanguageTags + - 1: NxWitness +- `build-datebadge-task.yml` + - 1: KiCadLibrary +- `publish-docker-readme-task.yml` + - 1: VSCode-Server-DotNetCore + +## Per Repo + +| Repo | Files | Lines | Identical to hub | Callers | Repo-local files | +| --- | --- | --- | --- | --- | --- | +| AudioCleaner | 2 | 134 | 55 | 0 | - | +| Blog | 6 | 560 | 317 | 0 | - | +| DevKitCIoT | 1 | 58 | 25 | 0 | - | +| ESPHome-Config | 3 | 450 | 90 | 0 | - | +| ESPHome-NonRoot | 11 | 1,154 | 463 | 0 | `check-upstream-dependency.yml`, `check-upstream-version.yml` | +| Financial-Modeling | 3 | 233 | 124 | 0 | - | +| HolidayLights | 1 | 53 | 25 | 0 | - | +| HomeAssistant-Config | 3 | 229 | 88 | 0 | - | +| HomeAutomation-Config | 4 | 248 | 144 | 0 | - | +| KiCadLibrary | 6 | 772 | 221 | 0 | - | +| LanguageTags | 7 | 724 | 265 | 0 | - | +| MediaTools | 5 | 530 | 173 | 0 | - | +| NxWitness | 10 | 1,102 | 363 | 0 | `build-base-images-task.yml` | +| PhotoCleaner | 8 | 827 | 457 | 1 | - | +| PlexCleaner | 8 | 805 | 455 | 0 | - | +| Utilities | 6 | 621 | 242 | 0 | - | +| VSCode-Server-DotNetCore | 8 | 563 | 297 | 0 | - | +| Vantage-Config | 3 | 229 | 88 | 0 | - | +| aiopurpleair | 6 | 611 | 198 | 0 | - | +| homeassistant-purpleair | 7 | 1,061 | 111 | 0 | `check-ha-version.yml`, `test-release-task.yml` | + +## Repo-Local Workflows + +A workflow no hub canonical names. Each is either genuinely repo-specific, and stays, or a candidate for a hub task with a hook, and the design doc lists which. + +- **ESPHome-NonRoot** `check-upstream-dependency.yml` (111 lines) +- **ESPHome-NonRoot** `check-upstream-version.yml` (41 lines) +- **NxWitness** `build-base-images-task.yml` (89 lines) +- **homeassistant-purpleair** `check-ha-version.yml` (316 lines) +- **homeassistant-purpleair** `test-release-task.yml` (248 lines) + +<!-- Repo --> + +[fidelity-model]: ../spec/fidelity-model.md +[reusable-workflows]: ../docs/reusable-workflows.md diff --git a/scripts/README.md b/scripts/README.md index fc185c10..ba12a39d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,228 +1,228 @@ -# Repo Scripts - -The fleet's checks and review tooling, run by hand, with the deterministic ones also gating CI. Each one exists because the CI linters pass on the failure it catches: `markdownlint`, `cspell`, `actionlint`, and `editorconfig-checker` all report clean on prose that breaks a documented [`GOVERNANCE.md`][governance] rule. The pre-commit hook runs two deterministic doc gates, each at the scope that fits it. `python3 scripts/prose_lint.py . --diff HEAD` is diff-scoped, at about 0.13 seconds where its whole-tree sweep costs about 2.2. `python3 scripts/repo_gate.py --check eol` is repo-wide, since it reads `.gitattributes` against `.editorconfig` and takes no file list, and at about 0.04 seconds there is nothing to scope. The earlier decision to keep doc linters out was made when a sweep was the only mode, and what reversed it is the diff scope rather than a change of preference. The gates needing Docker, and `sha-pin` which resolves a pin against the GitHub API, stay in CI. The hook reads the working tree rather than the index, so a partially staged file is judged on all of its edits, which CI's whole-tree run is the backstop for. - -**Hosted here and reached, never carried.** These are not declared in [`spec/files.json`][files], so the audit does not expect a downstream repo to ship them, the same footing as `spec/audit.py`. That is the fleet model rather than an omission: a script holding no per-repo content is one copy for the fleet, run from a hub checkout against the repository named on the command line, per [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling]. A repository that cannot reach the hub reports the check as not run rather than reconstructing it, since a rebuilt gate encodes its author's reading of the rule and agrees with no other repository. CI reaches the same rules through the [`prose-gate`][prose-gate-action] composite action, which a caller pins to a commit SHA. It reads the copy bundled at that pin only where the run targets `main`, and takes the rules from hub `develop` on every other target, a feature-branch push included, so a released repo's gate is reproducible while every branch below it exercises a rule change before that change reaches `main`. A caller wanting one specific hub ref passes `rules-ref` and overrides both. - -Python only, standard library only, no third-party packages. Every check script is read-only and exits non-zero on a finding. `build_dist.py` and `skills_install.py` below are the two exceptions, since a generator and an installer both exist to write, and each still offers a read-only mode (`--check`, `--report`) for CI and for asking without changing anything. - -The directory separates its kinds by name and by tree. A gate checks and exits non-zero on a finding, and its name carries a `_lint` or `_gate` suffix saying what it gates. `prose_lint.py` and `repo_gate.py` gate this tree in CI, and `host_gate.py` gates the machine it runs on. A utility does work rather than gating and carries no suffix: `build_dist.py`, `pr_review.py`, `skills_install.py`. The unit tests live apart under [`scripts/tests/`][tests], one `test_<script>.py` per script, driving its gates against input they must reject. A gate nobody has watched fail is a gate nobody knows works. Where a case covers a table it reads the live table rather than restating it, and each one asserts a floor on what a healthy run reaches, since a check whose scan matches nothing reports zero findings and reads exactly like a pass. - -```sh -python3 scripts/tests/test_prose_lint.py -python3 scripts/tests/test_repo_gate.py -python3 scripts/tests/test_pr_review.py -python3 scripts/tests/test_build_dist.py -python3 scripts/tests/test_skills_install.py -python3 -m unittest discover -s scripts/tests # all of them, and exits 5 if the suite vanishes -uvx coverage@latest run --source=scripts,spec,host-setup -m unittest discover -s scripts/tests && uvx coverage@latest report -``` - -## `prose_lint.py` - -Enforces the [`GOVERNANCE.md`][governance] "Documentation Style Conventions" rules that no linter checks: non-ASCII judged against the charset rule's three tiers, a semicolon in prose, a spaced hyphen joining or interrupting a sentence, a duplicated consecutive word, a British spelling, and the shape of a comment's prose. It carries one rule from elsewhere in that document, `home-path`, which comes from "Representative Data in Agent-Authored Text" and catches an absolute home path naming a real account. That rule closes the pattern-detectable sliver of its section and nothing beyond it, since the exposure the section exists for was name-shaped and no pattern finds a name. It is the one rule a checkout can turn off: an operational repository's runbook carries the literal path an operator types, so a run there drops `home-path` and says so on stderr rather than going quiet. A repository states its own model by which configuration payload it carries, and the hub carries both, so it reads as a release repository and the rule gates here. - -The tiers decide by context rather than by a flat ban. Tier 1 carries no meaning its ASCII form loses and always flags. Tier 2 is an operator, kept next to a figure or another operator and replaced between words, so a threshold table reads as the range it is. Tier 3 is a unit or scientific symbol whose ASCII form would be a lie and never flags. Developer-typed characters such as emoji are preserved regardless of tier, and an un-tiered one is still reported as `charset-unknown` until it is classified. - -A character in no tier is a `charset-unknown` finding rather than a silent pass, since a gate that allows whatever it does not recognize stops gating as the character set grows. Classifying one is a fleet-law edit, so CI surfaces it without blocking on it. - -Run it scoped to changed lines, matching the standing rule that existing prose is corrected as each file is next edited rather than swept: - -```sh -python3 scripts/prose_lint.py . --diff origin/develop -``` - -Whole-tree (`python3 scripts/prose_lint.py .`) reports zero, so a finding is a line the change under review wrote rather than backlog it inherited. CI gates every rule in the default set on that basis, except `charset-unknown`, which reports warn-only because classifying a character is a fleet-law edit rather than a prose fix. - -The default rule set covers comment shape (`comment-wrap` and `comment-case`) alongside the prose rules. It did not, which meant a run nobody parameterized reported clean on a wrapped comment while the rule read as enforced, and comment shape is the most frequently regressed rule in agent-authored work. Reading the backlog it exposes needs no flag now, and gating it needed `--diff` while the tree carried several hundred of them. That backlog is cleared, so both comment rules gate whole-tree, and `--diff` is now about scoping a run rather than about surviving one. - -A wide scan skips the trees this repo generates rather than authors, currently `reports/`, which [`spec/audit.py`][audit] writes. A finding there is the audit engine's phrasing rather than an author's, so no edit to that tree can fix it, and leaving them in made the repo's own number mostly generated output. Naming such a path directly still reads it (`prose_lint.py reports`), so nothing becomes uncheckable. - -The file set is what git tracks plus what it is not ignoring, and the diff scope counts an untracked file as added in full, so a new file is read before it is staged rather than after. That is the file set where git can describe the tree, and where it cannot the run warns and walks the filesystem instead, which applies no ignore rules and skips the generated roots by name. Everything a verdict rests on is read from the repository named on the command line: the rule set, the file set, the diff, and the repository-relative keys that join the last two. Reading any of them from the directory the process happens to stand in is what produced every false clean on record, including an absolute path argument that reported clean over a tree it had read in full. Each run states its scope on stderr, because a gate that read nothing prints what a gate with nothing to report prints. - -In Markdown an HTML comment carrying no sentence punctuation is treated as a structural marker rather than commentary, so it takes neither a capital nor a sentence split. The reference-link group headers, the ToC-omit directive, and the `agent-safety` install markers are each matched verbatim by a tool, so rewriting one to satisfy the rule breaks whatever reads it. A Markdown comment that does punctuate a sentence is prose and is judged as prose. - -The `spelling` rule covers the US English convention where cspell does not reach. That gate reads README and HISTORY only, deliberately, because gating every Markdown file would mean endlessly padding `cspell.json` with technical terms, so a British spelling anywhere else in the tree had nothing checking it. The banned words are generated from stems rather than listed one by one, since an inflected spelling is as wrong as its base and a hand-listed family drifts as soon as one form is added without the others. Two words are deliberately absent: `analyses` is the US plural of `analysis` as much as it is a British verb form, and `cancelled` is a GitHub Actions job status rather than prose. - -**Outside Markdown `spelling` and `dupword` read the comments, not the source lines**, reusing the extraction the `comment-wrap` rule already does. An identifier, a string literal, or a lookup table is code, and judging it as prose would make this script report its own table of banned words. Each comment on a line is judged on its own rather than joined with its neighbors, because two comments are two sentences and joining them reads the second's opening word as a repeat of the first's last. - -`dupword` gates CI, so its scope decides what a correct file is allowed to contain. A repeated token outside a comment is usually correct authoring rather than a typo: `class="gallery gallery-cols-1"` is the ordinary way two CSS class names share a prefix, and `rel`, `srcset`, `sizes` and the `data-*` attributes all take value lists of the same shape. There is no edit that satisfies the rule without changing the rendered page, so a blocking gate that reads those lines rejects correct work. The cost of the narrower scope is stated plainly rather than hidden: a duplicated word in HTML body text, or in a YAML or JSON string value, is no longer caught. Narrowing to the comment is preferred over exempting an attribute, since an exemption list covers only the attributes its author thought of. - -**Scope** is every text file git tracks, binaries skipped by a NUL-byte check, with no extension allowlist: an allowlist covers what its author thought of and silently stops covering whatever is added next, which is the same reason the line-endings rule already requires `git ls-files` over a raw `find`. `--list-files` prints the discovered set for auditing. - -A double-quoted span in Markdown is treated as a quotation and not scanned for prose rules, so a rule that states its own counter-example does not report the document that documents it. Outside Markdown a double quote is structural, so the prose inside it still counts. - -The `semicolon` and `dash` rules ban a construction rather than a detectable subset of it, so each flags by default and the exceptions are the ones the rule names: a semicolon inside a list that already carries commas, and for the dash a compound word, a leading list marker, a range, and the `- **Label** - explanation` separator that opens a governed bullet. - -**The semicolon rule reads the list where it lives.** The comma qualifies the list as a whole rather than one separator's position, so an enumeration whose commas fall in a later item keeps every semicolon it carries. Reading it positionally split one series in two, flagging the openers of the same list it then exempted the tail of, which would have restructured the enumerated guarantees the exemption exists to protect. A Markdown table row is judged one cell at a time, since a row is a record of fields and a comma in one column cannot excuse a semicolon in another, and a bullet's `**Label**:` is dropped before the line is read, because it opens the bullet rather than announcing a list, the same construct the label dash is exempted for. The colon is written inside the emphasis as often as outside it, so `**Label:**` is dropped on the same grounds, matching only one spelling having left the other announcing a list it never announced. - -**The sentence is the unit the exemption is judged on, because that is where a list lives.** The whole bullet decided it once, so a colon anywhere before the first semicolon marked the bullet a list and exempted every semicolon after it, however plainly one joined two independent clauses, and the two did not have to be near each other or related at all. Measured over this repo when it was fixed, the exemption was covering 62 spans holding 120 semicolons across 9 files while the rule reported none of them, so the gate read as clean over the docs it exists to check. Scoping it to the sentence reported 43 further semicolons and silenced none, with a 44th from dropping the other spelling of the label colon, and the sentence boundary is the run-on rule's, so an initial or an abbreviation ends nothing and a terminator closing inside emphasis or a bracket (`.**`, `.)`) still ends a sentence. The colon arm was measured before being kept rather than dropped: dropping it flagged 14 further lines, and those were genuine colon-introduced lists whose items carry commas, which is the standard use the rule names. - -**Both are Markdown-only for now.** A shell script carries 78 statement separators that are not prose at all, so telling a comment from code is a precondition for reaching source files. Until then a semicolon or dash in a code comment is missed, which reading the diff by eye still catches. - -The `comment-wrap` rule covers comments in every syntax the fleet's project types carry, not only the hash ones: `//` and `/* */` for C#, C, C++ and JSONC, `/* */` alone for CSS, `<!-- -->` for XML, csproj and Markdown, `<# #>` for PowerShell, `;` for INI, and `#` for Python, shell, YAML and TOML. - -JSON is treated as JSONC, because that is what ships: VS Code tasks, launch, devcontainer and workspace files all carry comments under a plain `.json` name. A marker inside a string literal is not a comment, so each line is scanned with quoted spans blanked first, and Python uses `tokenize` so a trailing comment is seen exactly. A documentation comment (`///`, `/**`, a docstring) is left to CODESTYLE, which permits the paragraphs this rule forbids. - -Each syntax also declares how its strings escape: the escape character, the quotes it works inside, and whether it works outside one, all read independently of whether a string embeds its delimiter by doubling it. Neither property implies the other: PowerShell's double-quoted string is escaped by a backtick and doubling at once, while a C# verbatim string is doubling and not escaped. Reading an escape a string does not have consumes its closing quote and blanks the rest of the line, and missing one it does have ends the string early on the escaped quote. - -A string that spans lines carries its state onto the lines it covers, so a marker inside one is data rather than a comment. Each syntax declares the forms it carries: the C# verbatim string, an ordinary quoted string in shell and in PowerShell, a PowerShell here-string, a shell heredoc, and a YAML block scalar. A YAML `run:` scalar is deliberately not one of them, because it holds a script whose `#` lines are exactly the comments this rule governs. A form no syntax declares stays scanned a line at a time, which is where a false positive is still possible: a TOML triple-quoted string is the open case. The reverse direction is guarded too, since a form that carried where the language has none would blank the rest of the file and report nothing: a YAML plain scalar's apostrophe is not a string, so an ordinary quote does not carry there. - -A comment sentence also has to start with a capital, which `comment-case` checks. A lowercase opening reads as the continuation of the line above it, so the two rules are read together: a wrapped sentence reports as `comment-wrap`, and a lowercase opening that is not a continuation reports as `comment-case`. Where the first word is a tool whose own casing is lowercase, the fix is to restructure rather than to capitalize the name against CODESTYLE's tooling-casing rule. - -**A comment body that is one token closing on a colon is a key or a heading rather than a sentence**, and neither rule applies to it. A `# ignore:` heading a commented-out block is disabled configuration, so the capitalization `comment-case` asks for corrupts the key a reader uncomments, and the rule damages the file it was pointed at. The token count carries the test, because a colon ending real prose always has words before it. Measured over this repo, the shape matches exactly two bodies, both of them a key or a heading, where the broader reading of a body with no whitespace at all covers 99 and sweeps in every reference URI and version marker with them. - -**A label opening a definition is exempt from `comment-case`, because the lowercase word is the name being defined.** A `# publish - 'true' when ...` documents an output named `publish`, and capitalizing it renames the output the workflow declares. This is the comment spelling of the `- **Label** - text` construct the dash rule already exempts, and it is the same reason a step marker is stripped before the sentence checks. The exemption costs a detection, stated rather than left to be found: a comment that genuinely opens a sentence on one word followed by a spaced dash is no longer read as a lowercase opening. It is scoped to a line that opens a definition and is never applied to one continuing a wrapped sentence, since a continuation whose first word takes a spaced dash is a parenthetical, which is the construction the dash rule exists to catch. Both live instances in this repo are continuations, so the corpus rather than the intuition chose the unit: the first design read any comment body and would have exempted both. - -**A comment whose whole body is a URI is a reference rather than a sentence**, and neither rule applies to it. It cannot be capitalized or restructured without corrupting the address it exists to carry, so before the exemption every repo carrying a reference block inherited a finding no edit could answer. Consecutive reference lines are separate addresses rather than one sentence wrapping, which is why the exemption also stops the line below a URI from reading as its continuation. A URI inside a sentence is still prose, so the exemption requires the whole body to be the address and nothing else. - -Every rule in the default set is clean tree-wide, which is what lets the CI gate read the whole tree rather than a diff. `charset-unknown` is the one that reports instead of blocking, for the reason the tier discussion above gives. - -## `repo_gate.py` - -Three deterministic checks: - -- `sha-pin`: every workflow `uses:` naming an action is a 40-hex commit SHA that resolves, with the one documented `dotnet/nbgv@master` exception allowed. A local reusable workflow (`./.github/workflows/...`) names no action and carries no ref to pin, so it is skipped. -- `eol`: every path pinned LF in [`.gitattributes`][gitattributes] has the matching [`.editorconfig`][editorconfig] override the line-ending rule requires, with EditorConfig brace syntax expanded. One direction only: an `.editorconfig` LF glob with no git pin is legitimate, since `.editorconfig` governs what the editor writes where git enforces a class it must not guess at. -- `eol-coverage`: the same pins read against the tree instead. A tracked file opening `#!` that git does not resolve to `eol=lf` is an interpreter line a CRLF checkout breaks, and a pin matching no tracked file is dead unless its block is marked `forward-declared`. - -```sh -python3 scripts/repo_gate.py -python3 scripts/repo_gate.py --check sha-pin -``` - -**`eol` and `eol-coverage` are separate checks because they answer different questions**, and widening the first in place would have made three published descriptions of it wrong at once. `eol` compares the two line-ending documents with each other, which is worth asking on its own, and it is document-to-document by design rather than by omission. What it cannot see is the tree: both documents can agree perfectly and both be wrong about the repository they describe, and nothing mechanical ever asked whether a pin binds a file that exists. [`spec/files.json`][files] marks `.gitattributes` as `intent`, so what stood in that gap was an agent reading the file for meaning during an audit. - -The gap is measured rather than argued. `ptr727/Blog` at `392de22` carried both shapes while `--check eol` reported `0 issue(s)`: `ops/vps-backup-pull` is an extensionless shebang script systemd runs unattended on a backup host, matched by `*.sh` and by no `.py` pin and left passive by `* -text`, and two pins named `deploy/` paths that have never been tracked in that repository in any commit. Run against the same commit, `eol-coverage` reports all three. The repository is where the issue that raised this came from (`ptr727/ProjectTemplate#633`), and it fixed its own copy first in `ptr727/Blog#69`. - -**The dead pin is the worse half, and not because a no-op pin costs anything.** The comment above Blog's two read "the deploy shell is an extensionless shebang script that matches no rule above", so the file asserted the extensionless case was handled while the one real instance sat unpinned twenty lines up. A dead pin does not merely fail to bind. It reads as coverage, which is what hid the live defect from every human and agent who opened that file. - -**A pin block marked `forward-declared` is exempt from the dead reading**, because in a carried baseline a pattern matching nothing is a declaration for whichever consumer adds the file, not a dead pin. Three of this repo's pins are exactly that today (`uv.lock`, `Dockerfile`, `*.Dockerfile`), and each goes live the moment a derived repo adds a lockfile or a Dockerfile, which [`.gitattributes`][gitattributes] already said in prose before anything read it. The mark reaches to the next blank line, which is how that file already groups a pin with its rationale, and it travels with the carried copy so a python repo holding the baseline without a lockfile stays exempt too. Two alternatives were rejected. An exception list inside this script restates data the scanned repository already carries and goes stale in the one direction nobody checks. Diffing against the hub's own `.gitattributes` needs no convention at all, and it was rejected because it makes a repository gate change verdict when a file in another repository changes, and it cannot run standalone. - -What the mark trades away is stated rather than left to be found: it reaches to the next blank line, so a pin appended directly under a marked block inherits an exemption nobody wrote for it, and that fails open. A case in the suite therefore names the three pins the marking is for and fails the moment a fourth arrives, rather than the parser growing a second rule. - -**Matching is gitattributes matching, not pathspec matching.** `git ls-files -- <pattern>` looks like the cheap way to ask whether a pin binds anything and is a different language: there `*` crosses a `/`, so `capture/*.py` also matches `capture/sub/x.py` and a dead pin reads as live. The attribute side has no such risk and takes no such care, since it delegates to `git check-attr` and so cannot disagree with what a checkout actually applies. - -**The shebang floor lives in the suite rather than in the check.** A source-only configuration repository shipping no scripts at all is legitimately clean, so a gate that failed on an empty scan would report a false finding in the common fleet case. What must not go unnoticed is this repository's own scan going quiet, and a case holds that instead. Every run still prints what it covered, for the reason `sha-pin` does. - -`eol` gained one `note:` of its own. Where `.editorconfig` sets `end_of_line = lf` for `[*]`, the matching override the check looks for is satisfied by the global default for **any** path, one that does not exist included, so the check is vacuously true for every pin it will ever read and its result carries no information about pin content. Blog is shaped that way, and so are the fleet repositories that declare `lineEndings: lf` in [`registry/repos.json`][repos]. The check does not fail there, since nothing is wrong with such a repository, and it says what it did not read. - -One question is deliberately **open**: the symmetric reading of `.editorconfig`, a path-specific section naming files that do not exist. It is not implemented here because this repo's own `[.github/workflows/*]` and `[catalog/snippets/workflows/*]` sections are legitimately broad, so the exemption needs measuring against the live corpus before a gate is built on it rather than after. - -**`sha-pin` resolves the pin as well as reading its shape**, because forty hex characters is a format any fabricated string satisfies, and an agent hand-writing a plausible SHA into a workflow is a failure this repo has seen rather than a hypothetical one. The `gh-write-guard` hook cannot cover it: the hook watches Bash, and an editor tool writing the same string into a file never reaches it. Resolving also catches the neighboring case, a pin whose commit was reachable only from a branch since squashed and deleted, which breaks a downstream gate long after the change that caused it. - -The resolution pass is **scoped to the scanned repository's own owner**, which is where the fleet's actions live and where that decay comes from, since a squash merge deletes the branch a pin was taken from and the pin outlives the commit. A third-party action's tag is stable by comparison, and reading one would make every local run of this gate depend on a stranger's repository answering. The cost is real and is stated rather than left to be found: a fabricated pin on a third-party action is still only shape-checked. Every run therefore prints what the pass actually covered, because a scope that resolves nothing prints the same `0 issue(s)` a full sweep does. On the hub today it covers nothing, since this repo's own `uses:` refs are all under other owners and the fleet's `ptr727` pins live in the downstream repos that consume [`prose-gate`][prose-gate-action]. Those are reached by running this gate from a hub checkout with `--root`, per the hosted-and-reached model above. - -A pin is a finding only where GitHub **answered** that the commit is absent, meaning a 404 or a 422. An offline host, a missing `gh`, a rate limit and a narrow token all report as unread and leave the pin on its shape, so the gate stays usable with no network instead of failing a correct tree. A 404 is confirmed against the repository itself before it becomes a finding, since an absent commit and a repository the credentials cannot see are the same answer from here, and a repository-scoped CI token is narrower than the fleet. That second read runs only on the failing path, and each distinct pin is read once however often it repeats. - -A `note:` line is how a check says it did less than its name. It prints under the check's own result, outside the issue count, and never changes the exit code, because nothing is wrong with the tree when the network is what is missing. It carries one fixed shape every run so a zero is as visible as a count, and it separates a pin under a known other owner from one whose owner could not be compared at all, since a checkout with no readable `origin` skips every pin including this owner's own and reporting those as somebody else's is the same false clean the note was added to prevent. - -A stale-backticked-path check was built and **rejected**: a template repo legitimately references paths that live in downstream repos, so it produced 34 false positives on a clean tree with no way to separate those from real drift. Doc-to-doc drift is a review lens, not a regex. - -## `host_gate.py` - -The host contract in [`docs/host-setup.md`][host-setup] as a check, reading the tool floors declared in [`spec/host-tools.json`][host-tools]. It exists because presence is the weaker half of that contract: both host defects this fleet has hit are version facts on a tool that is installed, answers `--version`, and looks healthy. - -**A floor is either measured or a target, and its `why` says which**, since a host failing one has a defect to point at where a host failing the other does not. A measured floor records the defect rather than a preference, and two exist today. A distribution `gh` in the `2.45.x` / `2.46.x` range is named broken by the GitHub CLI maintainers, and both `gh` limitations in [`OPERATIONS.md`][operations] were observed on one. A `git-restore-mtime` before `2025.08` calls `git whatchanged`, which current `git` refuses, so it restores nothing, prints its ordinary statistics and **exits 0**. Everything else is presence-only, which is deliberate, since a floor nobody can justify becomes a host failure nobody can act on. - -The three states a tool can be in are kept apart because their remedies differ: **absent** means install it, **unreadable** means the declared pattern is wrong and the fix is in this repo rather than on the host, and **read** means the floor applies. A probe that runs and exits non-zero is not an answer, which is what separates a tool that is missing from one this file cannot parse. - -A repository adds its own `host-tools.json` at its root and the gate layers it over the hub's, so a repo needing `ffmpeg`, or needing a tool the fleet calls optional, declares that where it is true. Layering is **tighten-only**: a local entry may add a tool, raise a floor, or turn an optional tool required, and may not lower a floor or turn a required tool optional, because those retire a fleet check from inside the repository it protects. A rejected relaxation is reported rather than dropped. - -## `pr_review.py` - -One compact digest of a pull request's Copilot review state, replacing a sequence of one-`gh`-call-per-turn polls. `status` prints the digest, `wait` runs the backoff in-process so a long review wait costs one agent turn instead of one per poll, `reply` answers one thread and resolves it, and `claims` reads the description against the branch it describes. Re-requesting a review stays out and its runbook is in [`.github/copilot-instructions.md`][copilot-instructions]. - -```sh -python3 scripts/pr_review.py status 452 --repo ptr727/ProjectTemplate -python3 scripts/pr_review.py claims 452 --repo ptr727/ProjectTemplate -python3 scripts/pr_review.py wait 452 --repo ptr727/ProjectTemplate --timeout 2700 -python3 scripts/pr_review.py reply 452 --repo ptr727/ProjectTemplate \ - --match "retry count is off by one" --body "Fixed in abc1234: the loop now stops at n." --resolve -``` - -`--repo` is required and carries no default. A default names one repository, and a run from anywhere else resolves its number there instead: the digest renders, every field is well-formed, and nothing in the output disagrees. Two runs read this repository's pull requests while their own was the subject, each caught by the maintainer rather than by the run. The digest leads with `repo=OWNER/NAME` for the same reason, since a number alone reads as correct in any repository. A value that is not `OWNER/NAME` is rejected by name rather than raised as an unpacking traceback, that being the near-miss a required argument still admits. - -`wait` exits `30` when the review is still pending at the timeout, which is pending rather than failed. Its failure mode is a wrong answer rather than a crash, so the cases feed crafted GraphQL payloads: a review attributed to the wrong login, a review counted against a stale head, a maintainer's own thread read as a finding, and a wait that returns success while nothing landed. One case reads the reviewer login out of the runbook rather than restating it, since GraphQL drops the `[bot]` suffix REST carries, and another holds the script to exactly the two mutations `reply` owns, so a third arriving is a write nobody reviewed as one. - -`wait` exits `40` when Copilot answers the request with a plain comment rather than a review, meaning a comment of its own that postdates its newest review on the pull request. The test is the **shape** of that answer and not its cause, which the script reads nothing of: a comment carries no commit, so it satisfies no coverage check whatever it says, and a wait reading formal reviews alone treats it as an unmet condition and then polls out its whole timeout against an answer that already arrived. A refusal is the case that makes this worth catching, a quota or rate-limit message among them, and `40` neither asserts nor detects one. The comment prints whole because its wording is the only thing separating a refusal, which is terminal since no review follows it and re-requesting does not clear it, from an ordinary remark that is not, so `40` ends the wait and hands the text to the reader who can tell them apart. A comment **older** than the newest review is spent rather than terminal, because the review it preceded did land. Every connection reads the newest `WINDOW` nodes rather than the reviewer's own, since GraphQL offers no author filter, so ordinary traffic is what pushes theirs out of reach. `window_blind` is the one guard over both sides, and each side fails differently. Blind on **comments** means an answer could be back there unseen, which reads as `answered_outside_review=unknown` rather than `no`. Blind on **reviews** is worse, because the newest review in view is then not the newest there is, and an empty baseline dates every comment as newer so each one reads as an answer: a false `40` that stops the loop on a pull request whose review actually landed. That case reports nothing and lets the wait keep polling, since a wait that runs on is visible where a wrong terminal is not. - -Everything else is decidable and says so. One of the reviewer's own nodes in view, even a **spent** one, settles the question, because nodes arrive in creation order, so anything behind the window is older than everything inside it. A window holding every node the pull request has is settled too, which is why the guard reads `pageInfo.hasPreviousPage` rather than the node count: a full window and a complete one are the same length, so length alone would report a gap where none exists. Cases hold `WINDOW` equal across all four windows and hold all four to asking for `hasPreviousPage`, since a connection that stops asking reports `no` instead of `unknown`, the silent narrowing one level up. `wait` exits `50` when the reviewer sits in the pending request set and no `copilot_work_started` follows the newest request, meaning nothing is acting on it and waiting on will not start it. That state is invisible from the reviews alone and indistinguishable from patience: one request sat thirteen and a half hours while the pull request read as waiting on the reviewer. Elapsed time cannot separate it from a slow round either, so the pickup event decides. It is the one thing here read over REST, since no GraphQL timeline item carries it, and it runs on its own interval rather than per poll: the first read comes after `--pickup-grace` (default five minutes), because inside that window a pending request is simply a review being worked on, and each later read waits another interval. One reading settles the request in front of it, and the next covers a request a push raises mid-wait, so a long wait costs a handful of REST calls instead of one per poll. The pickup is checked **before** the timeout, so the stall reports as itself instead of as `PENDING` once the clock runs out. Recovery stays out of this script: the digest names the state and the runbook carries the two mutations that clear and re-raise the request, neither of which has an id to hide and so neither of which is worth wrapping. The pending set is read through GraphQL rather than `gh pr view --json reviewRequests`, which omits a Bot reviewer outright and reports an empty set while Copilot sits in it. - -`wait` exits `41` when the review carrying the head is a **refusal**, meaning its body opens by saying it did not review. That answer arrives as a formal review, `state: COMMENTED`, with the correct commit and zero inline threads, so it satisfies every coverage check a clean pass does and renders a digest byte for byte identical to one. The `40` reasoning does not reach it, because that reasoning rests on a comment carrying no commit, and this carries the right one. A pull request of 301 changed files, one over the reviewer's limit of 300, read as `rounds=1 review_on_head=yes threads=0 unresolved=0 merge=CLEAN` and was one command from merging on a review that never ran. A refusal is therefore not coverage: `review_on_head` reports `NO`, the summary line carries a `refusal=YES` of its own, since `rounds=1 review_on_head=NO` is equally what a stale round looks like and the two want opposite responses, and the body prints whole because its wording is the only thing separating a file-count refusal, cleared by splitting the pull request, from a quota one, cleared by waiting. The script reads neither cause, only that the round declined. The match is on the body's **opening line**, since a refusal is the whole body where a review that merely quotes the wording carries it below its own overview, and this script and this file are exactly that quotation. One line rather than two, because a review's first line is its heading and its second is the overview prose: reading two passed every case except the review describing this check, which reported itself as a refusal of itself. The cost is the other direction, that a refusal introduced by a heading would sit below the opening and be missed, and answering that shape means telling a refusal from an overview rather than reading one line further. It is an alternation over the runbook's phrasings for the same reason the suppressed heading is, and a case asserts the script's pattern is the one the runbook publishes. The reading is **head-scoped**, unlike a suppressed finding, because a refusal is a statement about one commit that a push retires, and a genuine review of that same head outranks it, coverage that landed being coverage. The field is spent by that coverage as well as the exit code is, or the summary line reads `review_on_head=yes refusal=YES` and tells a reader to split a pull request the reviewer has just reviewed. The liveness query carries no bodies, so a refusal reads there as ordinary coverage. That is deliberate: it ends the wait, which is what a terminal outcome should do, and the full read every wait finishes with is what tells the two apart, so no exit code comes from the cheaper reading. - -`status` and `wait` both exit `42` where the round covering the head read **fewer files than the pull request changed**, and `43` where it states its coverage in a wording this script does not read. Coverage of the head was the only coverage anything checked, and coverage of the diff is a second reading stated in a line nothing parsed: a partial round carries the right `commit.oid`, raises no threads, and reports "generated no comments", so it is the clean pass byte for byte in everything read. Over 332 Copilot review bodies on this repository, five rounds across three pull requests reported reading fewer files than were changed and all three merged, one of them leaving a file of three unread across **both** its rounds. This is the third instance of the shape `refusal` and `suppressed` are the first two, and the only one nothing was reading. - -The reading fails closed, so a coverage-shaped line that parses to no counts is a failure whose remedy is stated as fixing this script rather than reading past it, which is what keeps the vetted spellings honest as the wording drifts, as it has once for each of the other two patterns. The tail of the sentence is deliberately outside the unit: it says how many comments the round raised, which is not coverage, and reading it would fail every merge over a sentence ending. There are two exemptions, and the first is the one that decides the design. **A body stating no coverage at all reads as `unstated`**, never as a pass and never as a failure: 28 of those 332 bodies are an overview and a change list and nothing more, that shape is current rather than historical and interleaves with the counted one throughout, and one pull request carries both across its two rounds, so failing on it would cry wolf on about one review in twelve and a guard an agent learns to work around is worse than none. **A refusal is exempt** because it states no coverage by design and is already classified, and reading it as a round would grow a spurious second failure on top of the one naming its remedy. - -The line is matched at its **start** rather than anywhere in the body, since both spellings are structural: across those bodies every coverage statement opens its line, 272 with the reviewer's own name and 32 as the `Review details` bullet, and none sits mid-sentence. A body-wide match reports the pull request that adds this check as a partial round, which is the false positive the suppressed matcher and the refusal matcher have each had once already, and fenced blocks are dropped for the same reason, 131 of the bodies carrying one and this change putting both spellings into the diff a review of it quotes. The cost is named rather than hidden: a wording that moves the statement off the line start reads as no statement rather than as one this cannot parse. The reading is **head-scoped**, unlike a suppressed finding and like a refusal, because a partial round describes one commit's diff and the push that changes that diff raises a round reading the whole of the new one, and where one head carries two rounds through a re-request the worst of them reports, since the one naming files it did not read is the one to answer. A case reads the vetted spellings out of the runbook and hands them to this script's own parser, so the pair stays in step in both directions. - -A partial round is reported and handed over rather than retried into. Measured over four pull requests and seven rounds, **every partial stayed partial at the identical ratio and no round ever recovered**, and the diff size does not predict it: fully covered pull requests here run to 33 files and 2,219 changed lines, well past every partial. The stated denominator matches the API's own changed-file count on 103 of 104 pull requests, so the reviewer counts the file and does not read it. Which file that is usually cannot be recovered, and the file table many bodies carry is why it is worth saying "usually" rather than "never": measured over 348 review bodies here and 121 on another repository in this fleet, that table names the whole changed set on partial and fully covered rounds alike, so a table naming every file is exactly what a fully covered round carries too and separates a miscount from a file nobody read exactly never. **One round of the seven is the exception**, stating 16 of 17 and naming 16, omitting `GOVERNANCE.md`, and it is the only evidence on record that the unread file is a real file rather than an artifact of counting. `status` therefore prints what the table says beside the counts and takes no verdict from it. It names the omitted file only where the table is short by exactly what the counts leave unread **and** names nothing outside the diff, one round here naming `GOVENANCE.md`, the reviewer's own spelling and a path no diff carries, which is enough of a reminder that the table is prose the reviewer writes rather than a list from the API. The comparison is head-scoped like the counts are, so a table from before a push describes the diff that push replaced and reports as no table rather than as a list of unreviewed files, which is the shape three of the four partials here carry. `wait` and `status` therefore report `42` as a state for the maintainer rather than one to re-request into. - -`status` and `wait` both exit `43` where the reviewer sent a **shape this script has no reader for**, and that outcome outranks every other reading here, because a reader that does not understand the output cannot be believed about what it found in it. Every rule in this script keys on a structural marker, so a marker that changes spelling is a section the reader stops finding and reports as absent. All three failures on record have that exact shape: the suppressed heading reworded and the count went to zero, the suppressed section moved inside another wrapper and the count went to zero again, and the coverage line was never parsed at all. Each reported a clean pass over a review it had misread, and each was found by the maintainer afterwards rather than by the gate. The digest carries `shapes=UNRECOGNIZED`, lists each shape it could not place, and names the remedy in two parts: **file an issue on the hub repository that hosts this script**, quoting the body the shape came from, and leave the merge decision to the maintainer, since an unrecognized shape does not say the pull request is bad, only that nothing here can vouch for the review of it. - -The vetted inventory is measured rather than imagined, and it is small because the output is regular. Across those 332 bodies, with fenced blocks dropped and text reduced to ASCII, the whole corpus is seven headings, six `<summary>` texts and three metadata labels. Counts are normalized to `(N)` and the verdict headings' colored circle is dropped before comparing, since both change on every review without the section having changed, and dropping the emoji is also what keeps this repository's charset rule satisfied. A body carrying **no** heading at all is itself unrecognized, which is what catches a rewrite that changes every marker at once, and a **refusal is exempt** because it is a bare paragraph by design and `REFUSAL` is its vetted spelling. That exemption is the pattern rather than a carve-out, so a refusal reworded stops being exempt and blocks, which is the refusal check's own failure mode caught one rewording later. The last reading is the quietest: a reviewer **login** that reads as this reviewer without being the spelling every query filters on, since a rename leaves every filter matching nothing and the digest then reports a review that landed as no review at all. A case runs the whole inventory over the measured corpus, where it raises nothing. -`wait` exits `44` when the review loop has closed but a required check sits in a shape no wait clears. The digest carries `checks=N/M` beside the merge word and names the shape as `stuck=`, because `mergeStateStatus` reports one word, `BLOCKED`, for a red check, a check nothing is running, an unresolved thread, and a missing approval alike, and a run in this repository spent twenty-five minutes polling that word on a pull request whose only unfinished check was an aggregator job no runner ever took. The cause came from the maintainer rather than from any field here, which is the whole defect: the digest named the state it could not explain and stopped there. Four shapes are told apart because each wants a different response. `NOT_POSTED` is a required status whose poster has not spoken, which is a `StatusContext`'s `EXPECTED` and only ever that. It is deliberately not folded in with the starved shape, since no runner is owed a status nothing has posted, so re-running a workflow clears nothing and the starved wording would send a reader at the runner pool over a missing poster. `NOT_PICKED_UP` is a check GitHub dispatched and assigned no runner, read from the queued state rather than from a runner name GraphQL does not carry, and the state suffices because a job held behind a `needs:` dependency does not enter the rollup until that dependency finishes, so there is no dependency-blocked queue to mistake for a starved one. Nothing agent-side starts it, since the pool is GitHub-hosted, so the remedy is a re-run or that capacity. `RUNNING_LONG` is deliberately the weaker reading and its wording says so, because duration alone cannot separate a hung job from a slow one: this repository's lint job legitimately runs nine to eleven minutes while its aggregator is a single shell conditional, so the threshold is generous, the elapsed time prints for the reader to judge against what the job costs, and nothing asserts a fault. `FAILED` is a verdict rather than a stuck check, reported so no reader deduces a red check from `BLOCKED`. - -A check merely still running normally is **not** any of these and exits `0`. That boundary is the whole design, because `wait` returns the moment coverage lands and on almost every pull request the checks are still going at that instant, so taking `44` for a pending check would make `44` the ordinary outcome and a code that fires always carries nothing. `44` additionally requires `mergeStateStatus: BLOCKED`, which the module docstring's own list of shapes has to name rather than only implying, because a reader who sees `stuck=FAILED` and exit `0` on a merge that is `UNSTABLE` should find the condition written down rather than infer the field is unreliable. It is because a rollup carries checks the ruleset does not require and four of the six on a green pull request here are exactly that, so the code borrows GitHub's own reading of which checks gate a merge rather than reading the ruleset's contexts over another call. `CLEAN` proves no required gate is outstanding whatever else the rollup is doing, and without that condition a stuck check nothing requires returns `44` on a mergeable pull request. The digest names the check either way, so the narrower code costs the reader nothing. Both of those came out of this change's own review. - -A rollup member that is neither a `CheckRun` nor a `StatusContext` is skipped rather than forced into one of those shapes, since forcing reads a label under a key the node does not use and a state that is not there, so an unknown member would arrive as an anonymous failure. Skipping it **quietly** would be the other half of the same mistake, because a check absent from the tally and the stuck reading renders as a clean pass over something never seen, which is this script's own core failure and not a case its newest field gets an exception from. So the member is carried as a marker, counted by neither reader, and named on a `CHECKS PARTIALLY UNREAD` line. `CHECKS_WINDOW` is substituted into `Q_FULL` rather than sitting beside a hard-coded `100`, since a constant that does not drive the query only documents the literal, and the case asserting the two agree holds only where someone runs it. The substitution is a `.replace` rather than an f-string because GraphQL is braces from end to end and interpolation would need every one of them doubled. The rendering path reads every field with `.get` and clamps a negative age to zero, for the reason `age` catches two exceptions: a caller handing the digest an odd node shape, or a clock behind GitHub's, should cost a field rather than the one call whose job is to report the state. Through the CLI the negative age is unreachable, since a negative age cannot exceed a non-negative threshold and the parser refuses a negative one, so that clamp is hardening on the library path rather than a live defect. - -The contexts connection is guarded the way the review and comment windows are, since it has the same failure. A rollup past a hundred contexts would drop the rest silently, so a required check among them would be absent from the tally and the stuck reading alike and the digest would render a clean pass over a check it never saw, which a fleet repository with a large matrix build reaches long before this one does. `CHECKS TRUNCATED` says so instead. The rollup is normalized once per digest and the list handed to both readers, since the parse is what this script exists to spend once, and the two readers calling it separately was invisible in the output, which is what let it pass. The `44` message is worded as a coincidence rather than a cause, because nothing here proves the stuck check is the blocker: `BLOCKED` is also worn by an open thread or a missing approval, so naming the check as the blocker would assert a link this cannot read. The normalized stamp key is `since` rather than `started`, since it holds a `CheckRun`'s `startedAt` for one shape and a `StatusContext`'s `createdAt` for the other, and one name over two different meanings reads like a comparison of equivalents. - -The rollup is selected by matching `headRefOid` rather than taken as the connection's first node, and where no commit matches, the digest says `CHECKS UNREADABLE` instead of reading another commit's rollup or letting `checks=0/0` pass for a fact about the head. A fallback to the newest node is the stale reading reached by a different route, and a silent `0/0` is the narrowing this whole script is built against, which its own newest field does not get an exception from. The case that was supposed to hold this asserted the *fixture's* commit equalled the head, which tests the payload rather than the code, and the code was reading position regardless. The reading of an age is guarded the same way: `age` catches `TypeError` as well as `ValueError`, because a stamp carrying no zone *parses* and yields a naive datetime that will not subtract from an aware `now`, so catching one and not the other lets a crash out of the call whose whole job is to report the state. `--check-grace` must be less than `--check-stall`, since asserting the two constants are ordered while leaving the flags free to invert them is the gap between a rule and its check one level down. All four came out of this change's own review, as low-confidence findings carrying no thread. - -The two rollup node shapes need translating rather than merely renaming, because `PENDING` is a member of both enums and means the opposite thing in each. A `CheckRun`'s `PENDING` is dispatched and not begun, so it belongs with the queued states, while a `StatusContext`'s reports a run the posting system says is under way, so it is judged against the stall rather than the grace. Read as unstarted, a long external build reports as queued with no runner assigned, naming a cause it does not have on a system that did pick it up. `EXPECTED` is the `StatusContext` state that genuinely means unposted, and leaving it out of the unstarted set sends it to the conclusion branch where it matches no pass and reports a required status nobody has posted as a red check. The grace is the pickup grace's five minutes for the reason that one is, as inside it a queued check is a check starting, and the stall is thirty minutes (`1800`) because a fleet repository building and testing .NET runs longer than this one does. Both are `--check-grace` and `--check-stall`, and a negative value for either is rejected by name rather than rendering a digest that reports every check stuck from its first read. A finished check carrying no conclusion yet reports nothing, since reading an absent verdict as a failure invents a red check out of a race in the API, and that case was caught by the suite rather than in review. An unrecognized conclusion **is** reported, because a new enum member read as a pass is a red check rendering as a green digest. `SKIPPED` and `NEUTRAL` are passes, as this fleet's aggregator pattern skips the conditional jobs and four of the six checks on a green pull request here are skips. The rollup rides the existing full query rather than a REST call, so the reading costs no extra round-trip, and it is asked of the last commit because a rollup hangs off a commit object, with a case holding that commit equal to `headRefOid` since a rollup read a push ago still renders every field. - -The timeout path prints the full digest for the same reason, as a bare `PENDING` line reports a slow reviewer and a broken poll identically, which is the reading that turns a stalled watcher into a watcher nobody notices is stalled. - -The digest also reports the **suppressed findings** a review body collapses into a `<details>` block. Those reach no review thread, so a loop that polls threads alone reports a clean pass while they stand, and the [merge gate][governance] counts them as outstanding findings either way. `suppressed=N` counts findings rather than blocks, reading the `(N)` the heading carries, since one body holds one block per round and counting blocks reports two findings as one. It covers **every** round rather than the current head, because a suppressed finding has no resolved state for a push to retire: head-scoping read "superseded by a push" as "answered", and a finding nobody replied to left the digest the moment the branch moved, so the run reported zero. That is how four rounds went unanswered across three pull requests in one day, each found by the maintainer rather than by this script. The summary line splits the count as `suppressed=N (on_head=N earlier=N)` and each block is marked with the round that raised it, since a finding on an older round may since be moot and deciding that is the reader's call rather than one the count should make for them. Each block prints whole where a thread body truncates, because a thread can be re-read at its id and a suppressed finding cannot, and it prints under a marker naming what closing it takes: no thread exists to reply on or resolve, so the answer goes in the PR conversation. - -`reply` posts one answer and resolves one thread, and it exists because the hand-run form keeps failing the same way rather than because a wrapper is tidier. Three instances are on record, each an agent that had read the rule against hand-typing a node id and reached for the literal regardless, the last of them refused by the `gh-write-guard` hook an hour after quoting that same rule in a pull request body. A shape that fails while the reader knows the rule is a shape to remove rather than a rule to restate, so the selector is the finding's own words and there is deliberately no argument a `PRRT_...` id fits in: the id is read from the query in the same run and passed straight to the mutation, and a case asserts the id the mutation carries is the one that run just read. The words are matched against the thread's opening comment rather than against a line number, because a fix push moves the line and every lookup keyed to one then misses, which is how three replies posted against nothing while the resolves still succeeded and closed the threads carrying no answer. Matching is case-insensitive, since the text is quoted back out of a digest by a reader. - -Every failure is a stop rather than a fallback, because each alternative closes a finding while leaving it unanswered, which is the state a reviewer reads as addressed. No match exits `60` and prints the open threads, since a no-match and an already-answered thread read identically from here. Two matches exit `61` and print both candidates rather than taking the first, `head -n 1` being how a reply lands on the wrong finding. A reply whose response carries no `url`, or a body that came back empty, exits `62` **without** resolving. A resolve that does not report `isResolved` exits `63`, with the reply already posted, so the thread is open behind an answer rather than silently assumed closed. `--resolve` is opt-in rather than the default, because a decline is resolved only once its evidence is in the thread. A target under an owner other than this checkout's exits `64` before anything is read at all, and that refusal takes no environment-variable escape: a grant this process can be handed is one the caller sets on the command that runs it, and a grant the caller writes for itself is not a grant, so the cross-owner case goes through the runbook's explicit `gh` path where the hook reads the maintainer's grant from the session instead. - -What this trades away is stated rather than glossed. A mutation spelled as a `gh` command in a shell is read by the `gh-write-guard` hook and one this script performs is not, since the hook sees `python3 pr_review.py reply` and no `gh` write. That is a real loss of a second pair of eyes, and it is taken because what the hook guards against there is a fabricated id, which this removes at the source instead of catching after the fact. The guard's other rule is re-implemented here rather than assumed: the owner check above is the same scope rule, enforced in-process, and it is honest that it stops a mistake rather than a determined caller. The whole-source guard against every other state-changing call stays and was narrowed to these two documents rather than dropped when the first of them arrived. - -`claims` checks that a description does not contradict its own branch, and it exists for the same reason `sha-pin` now resolves: a reference that points at nothing is a silent failure caught by a reviewer or not at all. Three stale descriptions in one session generated six review findings between them, each a reviewer noticing that the body named a commit or a behavior the branch no longer carried. It reads the commits a body **claims** the branch carries and the `uses:` refs it quotes, confirms the head tree still carries each, and exits `70` where one does not. Prose claims stay **out of scope**, since judging those needs a similarity heuristic, which [`spec/section-model.md`][section-model] rejects for exactly the reason it would fail here. - -**Scanning for bare SHAs was built first and the corpus rejected it.** Over the 25 most recent merged pull requests it raised four findings, and every one was correct prose: a `develop` commit named as history, a SHA inside a pasted digest, and two commits in `ptr727/Blog` written without a URL. Nothing in the *form* of a bare SHA separates those from a real claim, and separating them by meaning is the heuristic already ruled out above. A path arm was measured on the same corpus and is worse, flagging 54 of 215 backticked candidates, nearly all of them bare basenames, `origin/develop`, and other repositories. What survives is the verb: a commit counts only where the body says it was fixed, landed, shipped, added, introduced, corrected, resolved, carried or amended in it. That alternation raises exactly one reference over the same 25, and that one is true. It is an **inclusion** list, so a phrasing nobody thought of costs a detection rather than producing a finding, which is the direction to be incomplete in, and a claimed SHA still has to carry a digit as a backstop on the list growing later, since `accede` and `defaced` inflect into all-hex English words. - -A commit passes on **ancestry** rather than on membership of the branch's own commits, so a description may cite a commit it inherited from the base branch. A commit the repository does not carry at all and a commit this head does not descend from are both findings, and each names which it is, since the first is an amended-away SHA and the second is a branch cut elsewhere. The `uses:` refs are checked against the whole tree at head rather than a guessed set of workflow paths, because this repo carries `uses:` lines in catalog snippets and in documentation as well as under `.github/`, and a narrower surface would report a ref absent because it looked in the wrong place. One archive is also one request, where walking a listing costs a request per file and grows with the repository, and the match is on bytes so a file that does not decode is searched rather than skipped. - -Undecided is a third answer here for the reason it is one in `repo_gate.py`. A reference GitHub did not answer for is left undecided rather than reported stale, and where **every** reference is undecided the run exits `71` and says so, because `stale=0` from a check that read nothing renders exactly like `stale=0` from one that did. - -The match is on the block's heading rather than anywhere in the body, and on the runbook's alternation rather than on one phrasing, since the wording has already appeared two ways. A case asserts the script's pattern is the one the runbook publishes rather than a copy of it that can drift. Reading the whole body was the first implementation and its own review caught it: a review whose overview prose discusses suppressed findings carries none, and reporting that as a finding trains the reader to skim the field. A heading outside any `<details>` wrapper is still read, because reporting zero when the markup moves is the same false clean one level up, and that fallback takes a count so ordinary prose does not become one. - -## `build_dist.py` - -Regenerates [`.claude-plugin/fleet-skills/`][fleet-skills-dist] from [`.agents/skills/`][agents-skills], the hub's own hand-authored fleet Skills. Codex and opencode read `.agents/skills/` directly, project-local, with no install step. Claude Code scans neither that path nor any repo-root path by default, only `.claude/skills/` or a plugin's own `skills/`, so this script materializes a Claude-plugin-compatible copy for [`.claude-plugin/marketplace.json`][marketplace] to publish, nested under `.claude-plugin/` rather than a top-level `dist/`, since this repo's `.gitignore` already gives `dist/` a different, Python-build-artifact meaning. `.agents/skills/` stays the one place a skill is hand-edited, and `.claude-plugin/fleet-skills/` is generated and never hand-edited, the same discipline this fleet already applies to other derived trees. - -`--check` is the read-only mode: it exits `1` when the generated plugin was built from different source bytes than `.agents/skills/` currently holds, comparing a digest over every source file rather than a file-count or a timestamp, so a same-size edit still registers. CI runs `--check` rather than trusting a contributor to have run the generator, the same reason `spec/audit.py` exists rather than trusting a hand-carried file. - -## `skills_install.py` - -Installs the fleet's Skills for the current machine, cross-platform and idempotent, mirroring [`host-setup/agent-safety/install.py`][agent-safety-install]'s shape: `skills_install.sh` and `skills_install.ps1` are thin wrappers that locate a Python 3 interpreter and hand off, so every OS runs one tested code path. Two independent things happen on a run, since the three tools this fleet targets discover skills differently: `.agents/skills/` is materialized (not symlinked) to `$HOME/.agents/skills/`, so Codex and opencode's global scan covers every repo on the machine rather than only the one that happens to be open, and this repo's marketplace is registered with the `claude` CLI (`claude plugin marketplace add`, `claude plugin install`) so Claude Code loads the same content the other two read directly. The marketplace/plugin registration goes through the `claude` CLI's own commands rather than writing its internal `known_marketplaces.json` by hand, because that file's shape is the CLI's state, not a documented contract, and a hand-written copy risks drifting from what the CLI expects on its next release. - -`--report` reads the stamp a prior run wrote (`$HOME/.agents/skills-install-stamp.json`, naming the hub commit installed) against the current checkout and says whether the machine is current, without installing anything. A repository whose `AGENTS.md` keeps needing a rule restated is usually this: the machine was never installed, or was installed from an older commit. - -<!-- Internal --> - -[agent-safety-install]: ../host-setup/agent-safety/install.py -[agents-skills]: ../.agents/skills/README.md -[audit]: ../spec/audit.py -[copilot-instructions]: ../.github/copilot-instructions.md -[editorconfig]: ../.editorconfig -[files]: ../spec/files.json -[fleet-skills-dist]: ../.claude-plugin/fleet-skills/ -[gitattributes]: ../.gitattributes -[governance]: ../GOVERNANCE.md -[governance-hub-hosted-tooling]: ../GOVERNANCE.md#hub-hosted-tooling -[host-setup]: ../docs/host-setup.md -[host-tools]: ../spec/host-tools.json -[marketplace]: ../.claude-plugin/marketplace.json -[operations]: ../OPERATIONS.md -[prose-gate-action]: ../.github/actions/prose-gate/action.yml -[repos]: ../registry/repos.json -[section-model]: ../spec/section-model.md -[tests]: ./tests/ +# Repo Scripts + +The fleet's checks and review tooling, run by hand, with the deterministic ones also gating CI. Each one exists because the CI linters pass on the failure it catches: `markdownlint`, `cspell`, `actionlint`, and `editorconfig-checker` all report clean on prose that breaks a documented [`GOVERNANCE.md`][governance] rule. The pre-commit hook runs two deterministic doc gates, each at the scope that fits it. `python3 scripts/prose_lint.py . --diff HEAD` is diff-scoped, at about 0.13 seconds where its whole-tree sweep costs about 2.2. `python3 scripts/repo_gate.py --check eol` is repo-wide, since it reads `.gitattributes` against `.editorconfig` and takes no file list, and at about 0.04 seconds there is nothing to scope. The earlier decision to keep doc linters out was made when a sweep was the only mode, and what reversed it is the diff scope rather than a change of preference. The gates needing Docker, and `sha-pin` which resolves a pin against the GitHub API, stay in CI. The hook reads the working tree rather than the index, so a partially staged file is judged on all of its edits, which CI's whole-tree run is the backstop for. + +**Hosted here and reached, never carried.** These are not declared in [`spec/files.json`][files], so the audit does not expect a downstream repo to ship them, the same footing as `spec/audit.py`. That is the fleet model rather than an omission: a script holding no per-repo content is one copy for the fleet, run from a hub checkout against the repository named on the command line, per [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling]. A repository that cannot reach the hub reports the check as not run rather than reconstructing it, since a rebuilt gate encodes its author's reading of the rule and agrees with no other repository. CI reaches the same rules through the [`prose-gate`][prose-gate-action] composite action, which a caller pins to a commit SHA. It reads the copy bundled at that pin only where the run targets `main`, and takes the rules from hub `develop` on every other target, a feature-branch push included, so a released repo's gate is reproducible while every branch below it exercises a rule change before that change reaches `main`. A caller wanting one specific hub ref passes `rules-ref` and overrides both. + +Python only, standard library only, no third-party packages. Every check script is read-only and exits non-zero on a finding. `build_dist.py` and `skills_install.py` below are the two exceptions, since a generator and an installer both exist to write, and each still offers a read-only mode (`--check`, `--report`) for CI and for asking without changing anything. + +The directory separates its kinds by name and by tree. A gate checks and exits non-zero on a finding, and its name carries a `_lint` or `_gate` suffix saying what it gates. `prose_lint.py` and `repo_gate.py` gate this tree in CI, and `host_gate.py` gates the machine it runs on. A utility does work rather than gating and carries no suffix: `build_dist.py`, `pr_review.py`, `skills_install.py`. The unit tests live apart under [`scripts/tests/`][tests], one `test_<script>.py` per script, driving its gates against input they must reject. A gate nobody has watched fail is a gate nobody knows works. Where a case covers a table it reads the live table rather than restating it, and each one asserts a floor on what a healthy run reaches, since a check whose scan matches nothing reports zero findings and reads exactly like a pass. + +```sh +python3 scripts/tests/test_prose_lint.py +python3 scripts/tests/test_repo_gate.py +python3 scripts/tests/test_pr_review.py +python3 scripts/tests/test_build_dist.py +python3 scripts/tests/test_skills_install.py +python3 -m unittest discover -s scripts/tests # all of them, and exits 5 if the suite vanishes +uvx coverage@latest run --source=scripts,spec,host-setup -m unittest discover -s scripts/tests && uvx coverage@latest report +``` + +## `prose_lint.py` + +Enforces the [`GOVERNANCE.md`][governance] "Documentation Style Conventions" rules that no linter checks: non-ASCII judged against the charset rule's three tiers, a semicolon in prose, a spaced hyphen joining or interrupting a sentence, a duplicated consecutive word, a British spelling, and the shape of a comment's prose. It carries one rule from elsewhere in that document, `home-path`, which comes from "Representative Data in Agent-Authored Text" and catches an absolute home path naming a real account. That rule closes the pattern-detectable sliver of its section and nothing beyond it, since the exposure the section exists for was name-shaped and no pattern finds a name. It is the one rule a checkout can turn off: an operational repository's runbook carries the literal path an operator types, so a run there drops `home-path` and says so on stderr rather than going quiet. A repository states its own model by which configuration payload it carries, and the hub carries both, so it reads as a release repository and the rule gates here. + +The tiers decide by context rather than by a flat ban. Tier 1 carries no meaning its ASCII form loses and always flags. Tier 2 is an operator, kept next to a figure or another operator and replaced between words, so a threshold table reads as the range it is. Tier 3 is a unit or scientific symbol whose ASCII form would be a lie and never flags. Developer-typed characters such as emoji are preserved regardless of tier, and an un-tiered one is still reported as `charset-unknown` until it is classified. + +A character in no tier is a `charset-unknown` finding rather than a silent pass, since a gate that allows whatever it does not recognize stops gating as the character set grows. Classifying one is a fleet-law edit, so CI surfaces it without blocking on it. + +Run it scoped to changed lines, matching the standing rule that existing prose is corrected as each file is next edited rather than swept: + +```sh +python3 scripts/prose_lint.py . --diff origin/develop +``` + +Whole-tree (`python3 scripts/prose_lint.py .`) reports zero, so a finding is a line the change under review wrote rather than backlog it inherited. CI gates every rule in the default set on that basis, except `charset-unknown`, which reports warn-only because classifying a character is a fleet-law edit rather than a prose fix. + +The default rule set covers comment shape (`comment-wrap` and `comment-case`) alongside the prose rules. It did not, which meant a run nobody parameterized reported clean on a wrapped comment while the rule read as enforced, and comment shape is the most frequently regressed rule in agent-authored work. Reading the backlog it exposes needs no flag now, and gating it needed `--diff` while the tree carried several hundred of them. That backlog is cleared, so both comment rules gate whole-tree, and `--diff` is now about scoping a run rather than about surviving one. + +A wide scan skips the trees this repo generates rather than authors, currently `reports/`, which [`spec/audit.py`][audit] writes. A finding there is the audit engine's phrasing rather than an author's, so no edit to that tree can fix it, and leaving them in made the repo's own number mostly generated output. Naming such a path directly still reads it (`prose_lint.py reports`), so nothing becomes uncheckable. + +The file set is what git tracks plus what it is not ignoring, and the diff scope counts an untracked file as added in full, so a new file is read before it is staged rather than after. That is the file set where git can describe the tree, and where it cannot the run warns and walks the filesystem instead, which applies no ignore rules and skips the generated roots by name. Everything a verdict rests on is read from the repository named on the command line: the rule set, the file set, the diff, and the repository-relative keys that join the last two. Reading any of them from the directory the process happens to stand in is what produced every false clean on record, including an absolute path argument that reported clean over a tree it had read in full. Each run states its scope on stderr, because a gate that read nothing prints what a gate with nothing to report prints. + +In Markdown an HTML comment carrying no sentence punctuation is treated as a structural marker rather than commentary, so it takes neither a capital nor a sentence split. The reference-link group headers, the ToC-omit directive, and the `agent-safety` install markers are each matched verbatim by a tool, so rewriting one to satisfy the rule breaks whatever reads it. A Markdown comment that does punctuate a sentence is prose and is judged as prose. + +The `spelling` rule covers the US English convention where cspell does not reach. That gate reads README and HISTORY only, deliberately, because gating every Markdown file would mean endlessly padding `cspell.json` with technical terms, so a British spelling anywhere else in the tree had nothing checking it. The banned words are generated from stems rather than listed one by one, since an inflected spelling is as wrong as its base and a hand-listed family drifts as soon as one form is added without the others. Two words are deliberately absent: `analyses` is the US plural of `analysis` as much as it is a British verb form, and `cancelled` is a GitHub Actions job status rather than prose. + +**Outside Markdown `spelling` and `dupword` read the comments, not the source lines**, reusing the extraction the `comment-wrap` rule already does. An identifier, a string literal, or a lookup table is code, and judging it as prose would make this script report its own table of banned words. Each comment on a line is judged on its own rather than joined with its neighbors, because two comments are two sentences and joining them reads the second's opening word as a repeat of the first's last. + +`dupword` gates CI, so its scope decides what a correct file is allowed to contain. A repeated token outside a comment is usually correct authoring rather than a typo: `class="gallery gallery-cols-1"` is the ordinary way two CSS class names share a prefix, and `rel`, `srcset`, `sizes` and the `data-*` attributes all take value lists of the same shape. There is no edit that satisfies the rule without changing the rendered page, so a blocking gate that reads those lines rejects correct work. The cost of the narrower scope is stated plainly rather than hidden: a duplicated word in HTML body text, or in a YAML or JSON string value, is no longer caught. Narrowing to the comment is preferred over exempting an attribute, since an exemption list covers only the attributes its author thought of. + +**Scope** is every text file git tracks, binaries skipped by a NUL-byte check, with no extension allowlist: an allowlist covers what its author thought of and silently stops covering whatever is added next, which is the same reason the line-endings rule already requires `git ls-files` over a raw `find`. `--list-files` prints the discovered set for auditing. + +A double-quoted span in Markdown is treated as a quotation and not scanned for prose rules, so a rule that states its own counter-example does not report the document that documents it. Outside Markdown a double quote is structural, so the prose inside it still counts. + +The `semicolon` and `dash` rules ban a construction rather than a detectable subset of it, so each flags by default and the exceptions are the ones the rule names: a semicolon inside a list that already carries commas, and for the dash a compound word, a leading list marker, a range, and the `- **Label** - explanation` separator that opens a governed bullet. + +**The semicolon rule reads the list where it lives.** The comma qualifies the list as a whole rather than one separator's position, so an enumeration whose commas fall in a later item keeps every semicolon it carries. Reading it positionally split one series in two, flagging the openers of the same list it then exempted the tail of, which would have restructured the enumerated guarantees the exemption exists to protect. A Markdown table row is judged one cell at a time, since a row is a record of fields and a comma in one column cannot excuse a semicolon in another, and a bullet's `**Label**:` is dropped before the line is read, because it opens the bullet rather than announcing a list, the same construct the label dash is exempted for. The colon is written inside the emphasis as often as outside it, so `**Label:**` is dropped on the same grounds, matching only one spelling having left the other announcing a list it never announced. + +**The sentence is the unit the exemption is judged on, because that is where a list lives.** The whole bullet decided it once, so a colon anywhere before the first semicolon marked the bullet a list and exempted every semicolon after it, however plainly one joined two independent clauses, and the two did not have to be near each other or related at all. Measured over this repo when it was fixed, the exemption was covering 62 spans holding 120 semicolons across 9 files while the rule reported none of them, so the gate read as clean over the docs it exists to check. Scoping it to the sentence reported 43 further semicolons and silenced none, with a 44th from dropping the other spelling of the label colon, and the sentence boundary is the run-on rule's, so an initial or an abbreviation ends nothing and a terminator closing inside emphasis or a bracket (`.**`, `.)`) still ends a sentence. The colon arm was measured before being kept rather than dropped: dropping it flagged 14 further lines, and those were genuine colon-introduced lists whose items carry commas, which is the standard use the rule names. + +**Both are Markdown-only for now.** A shell script carries 78 statement separators that are not prose at all, so telling a comment from code is a precondition for reaching source files. Until then a semicolon or dash in a code comment is missed, which reading the diff by eye still catches. + +The `comment-wrap` rule covers comments in every syntax the fleet's project types carry, not only the hash ones: `//` and `/* */` for C#, C, C++ and JSONC, `/* */` alone for CSS, `<!-- -->` for XML, csproj and Markdown, `<# #>` for PowerShell, `;` for INI, and `#` for Python, shell, YAML and TOML. + +JSON is treated as JSONC, because that is what ships: VS Code tasks, launch, devcontainer and workspace files all carry comments under a plain `.json` name. A marker inside a string literal is not a comment, so each line is scanned with quoted spans blanked first, and Python uses `tokenize` so a trailing comment is seen exactly. A documentation comment (`///`, `/**`, a docstring) is left to CODESTYLE, which permits the paragraphs this rule forbids. + +Each syntax also declares how its strings escape: the escape character, the quotes it works inside, and whether it works outside one, all read independently of whether a string embeds its delimiter by doubling it. Neither property implies the other: PowerShell's double-quoted string is escaped by a backtick and doubling at once, while a C# verbatim string is doubling and not escaped. Reading an escape a string does not have consumes its closing quote and blanks the rest of the line, and missing one it does have ends the string early on the escaped quote. + +A string that spans lines carries its state onto the lines it covers, so a marker inside one is data rather than a comment. Each syntax declares the forms it carries: the C# verbatim string, an ordinary quoted string in shell and in PowerShell, a PowerShell here-string, a shell heredoc, and a YAML block scalar. A YAML `run:` scalar is deliberately not one of them, because it holds a script whose `#` lines are exactly the comments this rule governs. A form no syntax declares stays scanned a line at a time, which is where a false positive is still possible: a TOML triple-quoted string is the open case. The reverse direction is guarded too, since a form that carried where the language has none would blank the rest of the file and report nothing: a YAML plain scalar's apostrophe is not a string, so an ordinary quote does not carry there. + +A comment sentence also has to start with a capital, which `comment-case` checks. A lowercase opening reads as the continuation of the line above it, so the two rules are read together: a wrapped sentence reports as `comment-wrap`, and a lowercase opening that is not a continuation reports as `comment-case`. Where the first word is a tool whose own casing is lowercase, the fix is to restructure rather than to capitalize the name against CODESTYLE's tooling-casing rule. + +**A comment body that is one token closing on a colon is a key or a heading rather than a sentence**, and neither rule applies to it. A `# ignore:` heading a commented-out block is disabled configuration, so the capitalization `comment-case` asks for corrupts the key a reader uncomments, and the rule damages the file it was pointed at. The token count carries the test, because a colon ending real prose always has words before it. Measured over this repo, the shape matches exactly two bodies, both of them a key or a heading, where the broader reading of a body with no whitespace at all covers 99 and sweeps in every reference URI and version marker with them. + +**A label opening a definition is exempt from `comment-case`, because the lowercase word is the name being defined.** A `# publish - 'true' when ...` documents an output named `publish`, and capitalizing it renames the output the workflow declares. This is the comment spelling of the `- **Label** - text` construct the dash rule already exempts, and it is the same reason a step marker is stripped before the sentence checks. The exemption costs a detection, stated rather than left to be found: a comment that genuinely opens a sentence on one word followed by a spaced dash is no longer read as a lowercase opening. It is scoped to a line that opens a definition and is never applied to one continuing a wrapped sentence, since a continuation whose first word takes a spaced dash is a parenthetical, which is the construction the dash rule exists to catch. Both live instances in this repo are continuations, so the corpus rather than the intuition chose the unit: the first design read any comment body and would have exempted both. + +**A comment whose whole body is a URI is a reference rather than a sentence**, and neither rule applies to it. It cannot be capitalized or restructured without corrupting the address it exists to carry, so before the exemption every repo carrying a reference block inherited a finding no edit could answer. Consecutive reference lines are separate addresses rather than one sentence wrapping, which is why the exemption also stops the line below a URI from reading as its continuation. A URI inside a sentence is still prose, so the exemption requires the whole body to be the address and nothing else. + +Every rule in the default set is clean tree-wide, which is what lets the CI gate read the whole tree rather than a diff. `charset-unknown` is the one that reports instead of blocking, for the reason the tier discussion above gives. + +## `repo_gate.py` + +Three deterministic checks: + +- `sha-pin`: every workflow `uses:` naming an action is a 40-hex commit SHA that resolves, with the one documented `dotnet/nbgv@master` exception allowed. A local reusable workflow (`./.github/workflows/...`) names no action and carries no ref to pin, so it is skipped. +- `eol`: every path pinned LF in [`.gitattributes`][gitattributes] has the matching [`.editorconfig`][editorconfig] override the line-ending rule requires, with EditorConfig brace syntax expanded. One direction only: an `.editorconfig` LF glob with no git pin is legitimate, since `.editorconfig` governs what the editor writes where git enforces a class it must not guess at. +- `eol-coverage`: the same pins read against the tree instead. A tracked file opening `#!` that git does not resolve to `eol=lf` is an interpreter line a CRLF checkout breaks, and a pin matching no tracked file is dead unless its block is marked `forward-declared`. + +```sh +python3 scripts/repo_gate.py +python3 scripts/repo_gate.py --check sha-pin +``` + +**`eol` and `eol-coverage` are separate checks because they answer different questions**, and widening the first in place would have made three published descriptions of it wrong at once. `eol` compares the two line-ending documents with each other, which is worth asking on its own, and it is document-to-document by design rather than by omission. What it cannot see is the tree: both documents can agree perfectly and both be wrong about the repository they describe, and nothing mechanical ever asked whether a pin binds a file that exists. [`spec/files.json`][files] marks `.gitattributes` as `intent`, so what stood in that gap was an agent reading the file for meaning during an audit. + +The gap is measured rather than argued. `ptr727/Blog` at `392de22` carried both shapes while `--check eol` reported `0 issue(s)`: `ops/vps-backup-pull` is an extensionless shebang script systemd runs unattended on a backup host, matched by `*.sh` and by no `.py` pin and left passive by `* -text`, and two pins named `deploy/` paths that have never been tracked in that repository in any commit. Run against the same commit, `eol-coverage` reports all three. The repository is where the issue that raised this came from (`ptr727/ProjectTemplate#633`), and it fixed its own copy first in `ptr727/Blog#69`. + +**The dead pin is the worse half, and not because a no-op pin costs anything.** The comment above Blog's two read "the deploy shell is an extensionless shebang script that matches no rule above", so the file asserted the extensionless case was handled while the one real instance sat unpinned twenty lines up. A dead pin does not merely fail to bind. It reads as coverage, which is what hid the live defect from every human and agent who opened that file. + +**A pin block marked `forward-declared` is exempt from the dead reading**, because in a carried baseline a pattern matching nothing is a declaration for whichever consumer adds the file, not a dead pin. Three of this repo's pins are exactly that today (`uv.lock`, `Dockerfile`, `*.Dockerfile`), and each goes live the moment a derived repo adds a lockfile or a Dockerfile, which [`.gitattributes`][gitattributes] already said in prose before anything read it. The mark reaches to the next blank line, which is how that file already groups a pin with its rationale, and it travels with the carried copy so a python repo holding the baseline without a lockfile stays exempt too. Two alternatives were rejected. An exception list inside this script restates data the scanned repository already carries and goes stale in the one direction nobody checks. Diffing against the hub's own `.gitattributes` needs no convention at all, and it was rejected because it makes a repository gate change verdict when a file in another repository changes, and it cannot run standalone. + +What the mark trades away is stated rather than left to be found: it reaches to the next blank line, so a pin appended directly under a marked block inherits an exemption nobody wrote for it, and that fails open. A case in the suite therefore names the three pins the marking is for and fails the moment a fourth arrives, rather than the parser growing a second rule. + +**Matching is gitattributes matching, not pathspec matching.** `git ls-files -- <pattern>` looks like the cheap way to ask whether a pin binds anything and is a different language: there `*` crosses a `/`, so `capture/*.py` also matches `capture/sub/x.py` and a dead pin reads as live. The attribute side has no such risk and takes no such care, since it delegates to `git check-attr` and so cannot disagree with what a checkout actually applies. + +**The shebang floor lives in the suite rather than in the check.** A source-only configuration repository shipping no scripts at all is legitimately clean, so a gate that failed on an empty scan would report a false finding in the common fleet case. What must not go unnoticed is this repository's own scan going quiet, and a case holds that instead. Every run still prints what it covered, for the reason `sha-pin` does. + +`eol` gained one `note:` of its own. Where `.editorconfig` sets `end_of_line = lf` for `[*]`, the matching override the check looks for is satisfied by the global default for **any** path, one that does not exist included, so the check is vacuously true for every pin it will ever read and its result carries no information about pin content. Blog is shaped that way, and so are the fleet repositories that declare `lineEndings: lf` in [`registry/repos.json`][repos]. The check does not fail there, since nothing is wrong with such a repository, and it says what it did not read. + +One question is deliberately **open**: the symmetric reading of `.editorconfig`, a path-specific section naming files that do not exist. It is not implemented here because this repo's own `[.github/workflows/*]` and `[catalog/snippets/workflows/*]` sections are legitimately broad, so the exemption needs measuring against the live corpus before a gate is built on it rather than after. + +**`sha-pin` resolves the pin as well as reading its shape**, because forty hex characters is a format any fabricated string satisfies, and an agent hand-writing a plausible SHA into a workflow is a failure this repo has seen rather than a hypothetical one. The `gh-write-guard` hook cannot cover it: the hook watches Bash, and an editor tool writing the same string into a file never reaches it. Resolving also catches the neighboring case, a pin whose commit was reachable only from a branch since squashed and deleted, which breaks a downstream gate long after the change that caused it. + +The resolution pass is **scoped to the scanned repository's own owner**, which is where the fleet's actions live and where that decay comes from, since a squash merge deletes the branch a pin was taken from and the pin outlives the commit. A third-party action's tag is stable by comparison, and reading one would make every local run of this gate depend on a stranger's repository answering. The cost is real and is stated rather than left to be found: a fabricated pin on a third-party action is still only shape-checked. Every run therefore prints what the pass actually covered, because a scope that resolves nothing prints the same `0 issue(s)` a full sweep does. On the hub today it covers nothing, since this repo's own `uses:` refs are all under other owners and the fleet's `ptr727` pins live in the downstream repos that consume [`prose-gate`][prose-gate-action]. Those are reached by running this gate from a hub checkout with `--root`, per the hosted-and-reached model above. + +A pin is a finding only where GitHub **answered** that the commit is absent, meaning a 404 or a 422. An offline host, a missing `gh`, a rate limit and a narrow token all report as unread and leave the pin on its shape, so the gate stays usable with no network instead of failing a correct tree. A 404 is confirmed against the repository itself before it becomes a finding, since an absent commit and a repository the credentials cannot see are the same answer from here, and a repository-scoped CI token is narrower than the fleet. That second read runs only on the failing path, and each distinct pin is read once however often it repeats. + +A `note:` line is how a check says it did less than its name. It prints under the check's own result, outside the issue count, and never changes the exit code, because nothing is wrong with the tree when the network is what is missing. It carries one fixed shape every run so a zero is as visible as a count, and it separates a pin under a known other owner from one whose owner could not be compared at all, since a checkout with no readable `origin` skips every pin including this owner's own and reporting those as somebody else's is the same false clean the note was added to prevent. + +A stale-backticked-path check was built and **rejected**: a template repo legitimately references paths that live in downstream repos, so it produced 34 false positives on a clean tree with no way to separate those from real drift. Doc-to-doc drift is a review lens, not a regex. + +## `host_gate.py` + +The host contract in [`docs/host-setup.md`][host-setup] as a check, reading the tool floors declared in [`spec/host-tools.json`][host-tools]. It exists because presence is the weaker half of that contract: both host defects this fleet has hit are version facts on a tool that is installed, answers `--version`, and looks healthy. + +**A floor is either measured or a target, and its `why` says which**, since a host failing one has a defect to point at where a host failing the other does not. A measured floor records the defect rather than a preference, and two exist today. A distribution `gh` in the `2.45.x` / `2.46.x` range is named broken by the GitHub CLI maintainers, and both `gh` limitations in [`OPERATIONS.md`][operations] were observed on one. A `git-restore-mtime` before `2025.08` calls `git whatchanged`, which current `git` refuses, so it restores nothing, prints its ordinary statistics and **exits 0**. Everything else is presence-only, which is deliberate, since a floor nobody can justify becomes a host failure nobody can act on. + +The three states a tool can be in are kept apart because their remedies differ: **absent** means install it, **unreadable** means the declared pattern is wrong and the fix is in this repo rather than on the host, and **read** means the floor applies. A probe that runs and exits non-zero is not an answer, which is what separates a tool that is missing from one this file cannot parse. + +A repository adds its own `host-tools.json` at its root and the gate layers it over the hub's, so a repo needing `ffmpeg`, or needing a tool the fleet calls optional, declares that where it is true. Layering is **tighten-only**: a local entry may add a tool, raise a floor, or turn an optional tool required, and may not lower a floor or turn a required tool optional, because those retire a fleet check from inside the repository it protects. A rejected relaxation is reported rather than dropped. + +## `pr_review.py` + +One compact digest of a pull request's Copilot review state, replacing a sequence of one-`gh`-call-per-turn polls. `status` prints the digest, `wait` runs the backoff in-process so a long review wait costs one agent turn instead of one per poll, `reply` answers one thread and resolves it, and `claims` reads the description against the branch it describes. Re-requesting a review stays out and its runbook is in [`.github/copilot-instructions.md`][copilot-instructions]. + +```sh +python3 scripts/pr_review.py status 452 --repo ptr727/ProjectTemplate +python3 scripts/pr_review.py claims 452 --repo ptr727/ProjectTemplate +python3 scripts/pr_review.py wait 452 --repo ptr727/ProjectTemplate --timeout 2700 +python3 scripts/pr_review.py reply 452 --repo ptr727/ProjectTemplate \ + --match "retry count is off by one" --body "Fixed in abc1234: the loop now stops at n." --resolve +``` + +`--repo` is required and carries no default. A default names one repository, and a run from anywhere else resolves its number there instead: the digest renders, every field is well-formed, and nothing in the output disagrees. Two runs read this repository's pull requests while their own was the subject, each caught by the maintainer rather than by the run. The digest leads with `repo=OWNER/NAME` for the same reason, since a number alone reads as correct in any repository. A value that is not `OWNER/NAME` is rejected by name rather than raised as an unpacking traceback, that being the near-miss a required argument still admits. + +`wait` exits `30` when the review is still pending at the timeout, which is pending rather than failed. Its failure mode is a wrong answer rather than a crash, so the cases feed crafted GraphQL payloads: a review attributed to the wrong login, a review counted against a stale head, a maintainer's own thread read as a finding, and a wait that returns success while nothing landed. One case reads the reviewer login out of the runbook rather than restating it, since GraphQL drops the `[bot]` suffix REST carries, and another holds the script to exactly the two mutations `reply` owns, so a third arriving is a write nobody reviewed as one. + +`wait` exits `40` when Copilot answers the request with a plain comment rather than a review, meaning a comment of its own that postdates its newest review on the pull request. The test is the **shape** of that answer and not its cause, which the script reads nothing of: a comment carries no commit, so it satisfies no coverage check whatever it says, and a wait reading formal reviews alone treats it as an unmet condition and then polls out its whole timeout against an answer that already arrived. A refusal is the case that makes this worth catching, a quota or rate-limit message among them, and `40` neither asserts nor detects one. The comment prints whole because its wording is the only thing separating a refusal, which is terminal since no review follows it and re-requesting does not clear it, from an ordinary remark that is not, so `40` ends the wait and hands the text to the reader who can tell them apart. A comment **older** than the newest review is spent rather than terminal, because the review it preceded did land. Every connection reads the newest `WINDOW` nodes rather than the reviewer's own, since GraphQL offers no author filter, so ordinary traffic is what pushes theirs out of reach. `window_blind` is the one guard over both sides, and each side fails differently. Blind on **comments** means an answer could be back there unseen, which reads as `answered_outside_review=unknown` rather than `no`. Blind on **reviews** is worse, because the newest review in view is then not the newest there is, and an empty baseline dates every comment as newer so each one reads as an answer: a false `40` that stops the loop on a pull request whose review actually landed. That case reports nothing and lets the wait keep polling, since a wait that runs on is visible where a wrong terminal is not. + +Everything else is decidable and says so. One of the reviewer's own nodes in view, even a **spent** one, settles the question, because nodes arrive in creation order, so anything behind the window is older than everything inside it. A window holding every node the pull request has is settled too, which is why the guard reads `pageInfo.hasPreviousPage` rather than the node count: a full window and a complete one are the same length, so length alone would report a gap where none exists. Cases hold `WINDOW` equal across all four windows and hold all four to asking for `hasPreviousPage`, since a connection that stops asking reports `no` instead of `unknown`, the silent narrowing one level up. `wait` exits `50` when the reviewer sits in the pending request set and no `copilot_work_started` follows the newest request, meaning nothing is acting on it and waiting on will not start it. That state is invisible from the reviews alone and indistinguishable from patience: one request sat thirteen and a half hours while the pull request read as waiting on the reviewer. Elapsed time cannot separate it from a slow round either, so the pickup event decides. It is the one thing here read over REST, since no GraphQL timeline item carries it, and it runs on its own interval rather than per poll: the first read comes after `--pickup-grace` (default five minutes), because inside that window a pending request is simply a review being worked on, and each later read waits another interval. One reading settles the request in front of it, and the next covers a request a push raises mid-wait, so a long wait costs a handful of REST calls instead of one per poll. The pickup is checked **before** the timeout, so the stall reports as itself instead of as `PENDING` once the clock runs out. Recovery stays out of this script: the digest names the state and the runbook carries the two mutations that clear and re-raise the request, neither of which has an id to hide and so neither of which is worth wrapping. The pending set is read through GraphQL rather than `gh pr view --json reviewRequests`, which omits a Bot reviewer outright and reports an empty set while Copilot sits in it. + +`wait` exits `41` when the review carrying the head is a **refusal**, meaning its body opens by saying it did not review. That answer arrives as a formal review, `state: COMMENTED`, with the correct commit and zero inline threads, so it satisfies every coverage check a clean pass does and renders a digest byte for byte identical to one. The `40` reasoning does not reach it, because that reasoning rests on a comment carrying no commit, and this carries the right one. A pull request of 301 changed files, one over the reviewer's limit of 300, read as `rounds=1 review_on_head=yes threads=0 unresolved=0 merge=CLEAN` and was one command from merging on a review that never ran. A refusal is therefore not coverage: `review_on_head` reports `NO`, the summary line carries a `refusal=YES` of its own, since `rounds=1 review_on_head=NO` is equally what a stale round looks like and the two want opposite responses, and the body prints whole because its wording is the only thing separating a file-count refusal, cleared by splitting the pull request, from a quota one, cleared by waiting. The script reads neither cause, only that the round declined. The match is on the body's **opening line**, since a refusal is the whole body where a review that merely quotes the wording carries it below its own overview, and this script and this file are exactly that quotation. One line rather than two, because a review's first line is its heading and its second is the overview prose: reading two passed every case except the review describing this check, which reported itself as a refusal of itself. The cost is the other direction, that a refusal introduced by a heading would sit below the opening and be missed, and answering that shape means telling a refusal from an overview rather than reading one line further. It is an alternation over the runbook's phrasings for the same reason the suppressed heading is, and a case asserts the script's pattern is the one the runbook publishes. The reading is **head-scoped**, unlike a suppressed finding, because a refusal is a statement about one commit that a push retires, and a genuine review of that same head outranks it, coverage that landed being coverage. The field is spent by that coverage as well as the exit code is, or the summary line reads `review_on_head=yes refusal=YES` and tells a reader to split a pull request the reviewer has just reviewed. The liveness query carries no bodies, so a refusal reads there as ordinary coverage. That is deliberate: it ends the wait, which is what a terminal outcome should do, and the full read every wait finishes with is what tells the two apart, so no exit code comes from the cheaper reading. + +`status` and `wait` both exit `42` where the round covering the head read **fewer files than the pull request changed**, and `43` where it states its coverage in a wording this script does not read. Coverage of the head was the only coverage anything checked, and coverage of the diff is a second reading stated in a line nothing parsed: a partial round carries the right `commit.oid`, raises no threads, and reports "generated no comments", so it is the clean pass byte for byte in everything read. Over 332 Copilot review bodies on this repository, five rounds across three pull requests reported reading fewer files than were changed and all three merged, one of them leaving a file of three unread across **both** its rounds. This is the third instance of the shape `refusal` and `suppressed` are the first two, and the only one nothing was reading. + +The reading fails closed, so a coverage-shaped line that parses to no counts is a failure whose remedy is stated as fixing this script rather than reading past it, which is what keeps the vetted spellings honest as the wording drifts, as it has once for each of the other two patterns. The tail of the sentence is deliberately outside the unit: it says how many comments the round raised, which is not coverage, and reading it would fail every merge over a sentence ending. There are two exemptions, and the first is the one that decides the design. **A body stating no coverage at all reads as `unstated`**, never as a pass and never as a failure: 28 of those 332 bodies are an overview and a change list and nothing more, that shape is current rather than historical and interleaves with the counted one throughout, and one pull request carries both across its two rounds, so failing on it would cry wolf on about one review in twelve and a guard an agent learns to work around is worse than none. **A refusal is exempt** because it states no coverage by design and is already classified, and reading it as a round would grow a spurious second failure on top of the one naming its remedy. + +The line is matched at its **start** rather than anywhere in the body, since both spellings are structural: across those bodies every coverage statement opens its line, 272 with the reviewer's own name and 32 as the `Review details` bullet, and none sits mid-sentence. A body-wide match reports the pull request that adds this check as a partial round, which is the false positive the suppressed matcher and the refusal matcher have each had once already, and fenced blocks are dropped for the same reason, 131 of the bodies carrying one and this change putting both spellings into the diff a review of it quotes. The cost is named rather than hidden: a wording that moves the statement off the line start reads as no statement rather than as one this cannot parse. The reading is **head-scoped**, unlike a suppressed finding and like a refusal, because a partial round describes one commit's diff and the push that changes that diff raises a round reading the whole of the new one, and where one head carries two rounds through a re-request the worst of them reports, since the one naming files it did not read is the one to answer. A case reads the vetted spellings out of the runbook and hands them to this script's own parser, so the pair stays in step in both directions. + +A partial round is reported and handed over rather than retried into. Measured over four pull requests and seven rounds, **every partial stayed partial at the identical ratio and no round ever recovered**, and the diff size does not predict it: fully covered pull requests here run to 33 files and 2,219 changed lines, well past every partial. The stated denominator matches the API's own changed-file count on 103 of 104 pull requests, so the reviewer counts the file and does not read it. Which file that is usually cannot be recovered, and the file table many bodies carry is why it is worth saying "usually" rather than "never": measured over 348 review bodies here and 121 on another repository in this fleet, that table names the whole changed set on partial and fully covered rounds alike, so a table naming every file is exactly what a fully covered round carries too and separates a miscount from a file nobody read exactly never. **One round of the seven is the exception**, stating 16 of 17 and naming 16, omitting `GOVERNANCE.md`, and it is the only evidence on record that the unread file is a real file rather than an artifact of counting. `status` therefore prints what the table says beside the counts and takes no verdict from it. It names the omitted file only where the table is short by exactly what the counts leave unread **and** names nothing outside the diff, one round here naming `GOVENANCE.md`, the reviewer's own spelling and a path no diff carries, which is enough of a reminder that the table is prose the reviewer writes rather than a list from the API. The comparison is head-scoped like the counts are, so a table from before a push describes the diff that push replaced and reports as no table rather than as a list of unreviewed files, which is the shape three of the four partials here carry. `wait` and `status` therefore report `42` as a state for the maintainer rather than one to re-request into. + +`status` and `wait` both exit `43` where the reviewer sent a **shape this script has no reader for**, and that outcome outranks every other reading here, because a reader that does not understand the output cannot be believed about what it found in it. Every rule in this script keys on a structural marker, so a marker that changes spelling is a section the reader stops finding and reports as absent. All three failures on record have that exact shape: the suppressed heading reworded and the count went to zero, the suppressed section moved inside another wrapper and the count went to zero again, and the coverage line was never parsed at all. Each reported a clean pass over a review it had misread, and each was found by the maintainer afterwards rather than by the gate. The digest carries `shapes=UNRECOGNIZED`, lists each shape it could not place, and names the remedy in two parts: **file an issue on the hub repository that hosts this script**, quoting the body the shape came from, and leave the merge decision to the maintainer, since an unrecognized shape does not say the pull request is bad, only that nothing here can vouch for the review of it. + +The vetted inventory is measured rather than imagined, and it is small because the output is regular. Across those 332 bodies, with fenced blocks dropped and text reduced to ASCII, the whole corpus is seven headings, six `<summary>` texts and three metadata labels. Counts are normalized to `(N)` and the verdict headings' colored circle is dropped before comparing, since both change on every review without the section having changed, and dropping the emoji is also what keeps this repository's charset rule satisfied. A body carrying **no** heading at all is itself unrecognized, which is what catches a rewrite that changes every marker at once, and a **refusal is exempt** because it is a bare paragraph by design and `REFUSAL` is its vetted spelling. That exemption is the pattern rather than a carve-out, so a refusal reworded stops being exempt and blocks, which is the refusal check's own failure mode caught one rewording later. The last reading is the quietest: a reviewer **login** that reads as this reviewer without being the spelling every query filters on, since a rename leaves every filter matching nothing and the digest then reports a review that landed as no review at all. A case runs the whole inventory over the measured corpus, where it raises nothing. +`wait` exits `44` when the review loop has closed but a required check sits in a shape no wait clears. The digest carries `checks=N/M` beside the merge word and names the shape as `stuck=`, because `mergeStateStatus` reports one word, `BLOCKED`, for a red check, a check nothing is running, an unresolved thread, and a missing approval alike, and a run in this repository spent twenty-five minutes polling that word on a pull request whose only unfinished check was an aggregator job no runner ever took. The cause came from the maintainer rather than from any field here, which is the whole defect: the digest named the state it could not explain and stopped there. Four shapes are told apart because each wants a different response. `NOT_POSTED` is a required status whose poster has not spoken, which is a `StatusContext`'s `EXPECTED` and only ever that. It is deliberately not folded in with the starved shape, since no runner is owed a status nothing has posted, so re-running a workflow clears nothing and the starved wording would send a reader at the runner pool over a missing poster. `NOT_PICKED_UP` is a check GitHub dispatched and assigned no runner, read from the queued state rather than from a runner name GraphQL does not carry, and the state suffices because a job held behind a `needs:` dependency does not enter the rollup until that dependency finishes, so there is no dependency-blocked queue to mistake for a starved one. Nothing agent-side starts it, since the pool is GitHub-hosted, so the remedy is a re-run or that capacity. `RUNNING_LONG` is deliberately the weaker reading and its wording says so, because duration alone cannot separate a hung job from a slow one: this repository's lint job legitimately runs nine to eleven minutes while its aggregator is a single shell conditional, so the threshold is generous, the elapsed time prints for the reader to judge against what the job costs, and nothing asserts a fault. `FAILED` is a verdict rather than a stuck check, reported so no reader deduces a red check from `BLOCKED`. + +A check merely still running normally is **not** any of these and exits `0`. That boundary is the whole design, because `wait` returns the moment coverage lands and on almost every pull request the checks are still going at that instant, so taking `44` for a pending check would make `44` the ordinary outcome and a code that fires always carries nothing. `44` additionally requires `mergeStateStatus: BLOCKED`, which the module docstring's own list of shapes has to name rather than only implying, because a reader who sees `stuck=FAILED` and exit `0` on a merge that is `UNSTABLE` should find the condition written down rather than infer the field is unreliable. It is because a rollup carries checks the ruleset does not require and four of the six on a green pull request here are exactly that, so the code borrows GitHub's own reading of which checks gate a merge rather than reading the ruleset's contexts over another call. `CLEAN` proves no required gate is outstanding whatever else the rollup is doing, and without that condition a stuck check nothing requires returns `44` on a mergeable pull request. The digest names the check either way, so the narrower code costs the reader nothing. Both of those came out of this change's own review. + +A rollup member that is neither a `CheckRun` nor a `StatusContext` is skipped rather than forced into one of those shapes, since forcing reads a label under a key the node does not use and a state that is not there, so an unknown member would arrive as an anonymous failure. Skipping it **quietly** would be the other half of the same mistake, because a check absent from the tally and the stuck reading renders as a clean pass over something never seen, which is this script's own core failure and not a case its newest field gets an exception from. So the member is carried as a marker, counted by neither reader, and named on a `CHECKS PARTIALLY UNREAD` line. `CHECKS_WINDOW` is substituted into `Q_FULL` rather than sitting beside a hard-coded `100`, since a constant that does not drive the query only documents the literal, and the case asserting the two agree holds only where someone runs it. The substitution is a `.replace` rather than an f-string because GraphQL is braces from end to end and interpolation would need every one of them doubled. The rendering path reads every field with `.get` and clamps a negative age to zero, for the reason `age` catches two exceptions: a caller handing the digest an odd node shape, or a clock behind GitHub's, should cost a field rather than the one call whose job is to report the state. Through the CLI the negative age is unreachable, since a negative age cannot exceed a non-negative threshold and the parser refuses a negative one, so that clamp is hardening on the library path rather than a live defect. + +The contexts connection is guarded the way the review and comment windows are, since it has the same failure. A rollup past a hundred contexts would drop the rest silently, so a required check among them would be absent from the tally and the stuck reading alike and the digest would render a clean pass over a check it never saw, which a fleet repository with a large matrix build reaches long before this one does. `CHECKS TRUNCATED` says so instead. The rollup is normalized once per digest and the list handed to both readers, since the parse is what this script exists to spend once, and the two readers calling it separately was invisible in the output, which is what let it pass. The `44` message is worded as a coincidence rather than a cause, because nothing here proves the stuck check is the blocker: `BLOCKED` is also worn by an open thread or a missing approval, so naming the check as the blocker would assert a link this cannot read. The normalized stamp key is `since` rather than `started`, since it holds a `CheckRun`'s `startedAt` for one shape and a `StatusContext`'s `createdAt` for the other, and one name over two different meanings reads like a comparison of equivalents. + +The rollup is selected by matching `headRefOid` rather than taken as the connection's first node, and where no commit matches, the digest says `CHECKS UNREADABLE` instead of reading another commit's rollup or letting `checks=0/0` pass for a fact about the head. A fallback to the newest node is the stale reading reached by a different route, and a silent `0/0` is the narrowing this whole script is built against, which its own newest field does not get an exception from. The case that was supposed to hold this asserted the *fixture's* commit equalled the head, which tests the payload rather than the code, and the code was reading position regardless. The reading of an age is guarded the same way: `age` catches `TypeError` as well as `ValueError`, because a stamp carrying no zone *parses* and yields a naive datetime that will not subtract from an aware `now`, so catching one and not the other lets a crash out of the call whose whole job is to report the state. `--check-grace` must be less than `--check-stall`, since asserting the two constants are ordered while leaving the flags free to invert them is the gap between a rule and its check one level down. All four came out of this change's own review, as low-confidence findings carrying no thread. + +The two rollup node shapes need translating rather than merely renaming, because `PENDING` is a member of both enums and means the opposite thing in each. A `CheckRun`'s `PENDING` is dispatched and not begun, so it belongs with the queued states, while a `StatusContext`'s reports a run the posting system says is under way, so it is judged against the stall rather than the grace. Read as unstarted, a long external build reports as queued with no runner assigned, naming a cause it does not have on a system that did pick it up. `EXPECTED` is the `StatusContext` state that genuinely means unposted, and leaving it out of the unstarted set sends it to the conclusion branch where it matches no pass and reports a required status nobody has posted as a red check. The grace is the pickup grace's five minutes for the reason that one is, as inside it a queued check is a check starting, and the stall is thirty minutes (`1800`) because a fleet repository building and testing .NET runs longer than this one does. Both are `--check-grace` and `--check-stall`, and a negative value for either is rejected by name rather than rendering a digest that reports every check stuck from its first read. A finished check carrying no conclusion yet reports nothing, since reading an absent verdict as a failure invents a red check out of a race in the API, and that case was caught by the suite rather than in review. An unrecognized conclusion **is** reported, because a new enum member read as a pass is a red check rendering as a green digest. `SKIPPED` and `NEUTRAL` are passes, as this fleet's aggregator pattern skips the conditional jobs and four of the six checks on a green pull request here are skips. The rollup rides the existing full query rather than a REST call, so the reading costs no extra round-trip, and it is asked of the last commit because a rollup hangs off a commit object, with a case holding that commit equal to `headRefOid` since a rollup read a push ago still renders every field. + +The timeout path prints the full digest for the same reason, as a bare `PENDING` line reports a slow reviewer and a broken poll identically, which is the reading that turns a stalled watcher into a watcher nobody notices is stalled. + +The digest also reports the **suppressed findings** a review body collapses into a `<details>` block. Those reach no review thread, so a loop that polls threads alone reports a clean pass while they stand, and the [merge gate][governance] counts them as outstanding findings either way. `suppressed=N` counts findings rather than blocks, reading the `(N)` the heading carries, since one body holds one block per round and counting blocks reports two findings as one. It covers **every** round rather than the current head, because a suppressed finding has no resolved state for a push to retire: head-scoping read "superseded by a push" as "answered", and a finding nobody replied to left the digest the moment the branch moved, so the run reported zero. That is how four rounds went unanswered across three pull requests in one day, each found by the maintainer rather than by this script. The summary line splits the count as `suppressed=N (on_head=N earlier=N)` and each block is marked with the round that raised it, since a finding on an older round may since be moot and deciding that is the reader's call rather than one the count should make for them. Each block prints whole where a thread body truncates, because a thread can be re-read at its id and a suppressed finding cannot, and it prints under a marker naming what closing it takes: no thread exists to reply on or resolve, so the answer goes in the PR conversation. + +`reply` posts one answer and resolves one thread, and it exists because the hand-run form keeps failing the same way rather than because a wrapper is tidier. Three instances are on record, each an agent that had read the rule against hand-typing a node id and reached for the literal regardless, the last of them refused by the `gh-write-guard` hook an hour after quoting that same rule in a pull request body. A shape that fails while the reader knows the rule is a shape to remove rather than a rule to restate, so the selector is the finding's own words and there is deliberately no argument a `PRRT_...` id fits in: the id is read from the query in the same run and passed straight to the mutation, and a case asserts the id the mutation carries is the one that run just read. The words are matched against the thread's opening comment rather than against a line number, because a fix push moves the line and every lookup keyed to one then misses, which is how three replies posted against nothing while the resolves still succeeded and closed the threads carrying no answer. Matching is case-insensitive, since the text is quoted back out of a digest by a reader. + +Every failure is a stop rather than a fallback, because each alternative closes a finding while leaving it unanswered, which is the state a reviewer reads as addressed. No match exits `60` and prints the open threads, since a no-match and an already-answered thread read identically from here. Two matches exit `61` and print both candidates rather than taking the first, `head -n 1` being how a reply lands on the wrong finding. A reply whose response carries no `url`, or a body that came back empty, exits `62` **without** resolving. A resolve that does not report `isResolved` exits `63`, with the reply already posted, so the thread is open behind an answer rather than silently assumed closed. `--resolve` is opt-in rather than the default, because a decline is resolved only once its evidence is in the thread. A target under an owner other than this checkout's exits `64` before anything is read at all, and that refusal takes no environment-variable escape: a grant this process can be handed is one the caller sets on the command that runs it, and a grant the caller writes for itself is not a grant, so the cross-owner case goes through the runbook's explicit `gh` path where the hook reads the maintainer's grant from the session instead. + +What this trades away is stated rather than glossed. A mutation spelled as a `gh` command in a shell is read by the `gh-write-guard` hook and one this script performs is not, since the hook sees `python3 pr_review.py reply` and no `gh` write. That is a real loss of a second pair of eyes, and it is taken because what the hook guards against there is a fabricated id, which this removes at the source instead of catching after the fact. The guard's other rule is re-implemented here rather than assumed: the owner check above is the same scope rule, enforced in-process, and it is honest that it stops a mistake rather than a determined caller. The whole-source guard against every other state-changing call stays and was narrowed to these two documents rather than dropped when the first of them arrived. + +`claims` checks that a description does not contradict its own branch, and it exists for the same reason `sha-pin` now resolves: a reference that points at nothing is a silent failure caught by a reviewer or not at all. Three stale descriptions in one session generated six review findings between them, each a reviewer noticing that the body named a commit or a behavior the branch no longer carried. It reads the commits a body **claims** the branch carries and the `uses:` refs it quotes, confirms the head tree still carries each, and exits `70` where one does not. Prose claims stay **out of scope**, since judging those needs a similarity heuristic, which [`spec/section-model.md`][section-model] rejects for exactly the reason it would fail here. + +**Scanning for bare SHAs was built first and the corpus rejected it.** Over the 25 most recent merged pull requests it raised four findings, and every one was correct prose: a `develop` commit named as history, a SHA inside a pasted digest, and two commits in `ptr727/Blog` written without a URL. Nothing in the *form* of a bare SHA separates those from a real claim, and separating them by meaning is the heuristic already ruled out above. A path arm was measured on the same corpus and is worse, flagging 54 of 215 backticked candidates, nearly all of them bare basenames, `origin/develop`, and other repositories. What survives is the verb: a commit counts only where the body says it was fixed, landed, shipped, added, introduced, corrected, resolved, carried or amended in it. That alternation raises exactly one reference over the same 25, and that one is true. It is an **inclusion** list, so a phrasing nobody thought of costs a detection rather than producing a finding, which is the direction to be incomplete in, and a claimed SHA still has to carry a digit as a backstop on the list growing later, since `accede` and `defaced` inflect into all-hex English words. + +A commit passes on **ancestry** rather than on membership of the branch's own commits, so a description may cite a commit it inherited from the base branch. A commit the repository does not carry at all and a commit this head does not descend from are both findings, and each names which it is, since the first is an amended-away SHA and the second is a branch cut elsewhere. The `uses:` refs are checked against the whole tree at head rather than a guessed set of workflow paths, because this repo carries `uses:` lines in catalog snippets and in documentation as well as under `.github/`, and a narrower surface would report a ref absent because it looked in the wrong place. One archive is also one request, where walking a listing costs a request per file and grows with the repository, and the match is on bytes so a file that does not decode is searched rather than skipped. + +Undecided is a third answer here for the reason it is one in `repo_gate.py`. A reference GitHub did not answer for is left undecided rather than reported stale, and where **every** reference is undecided the run exits `71` and says so, because `stale=0` from a check that read nothing renders exactly like `stale=0` from one that did. + +The match is on the block's heading rather than anywhere in the body, and on the runbook's alternation rather than on one phrasing, since the wording has already appeared two ways. A case asserts the script's pattern is the one the runbook publishes rather than a copy of it that can drift. Reading the whole body was the first implementation and its own review caught it: a review whose overview prose discusses suppressed findings carries none, and reporting that as a finding trains the reader to skim the field. A heading outside any `<details>` wrapper is still read, because reporting zero when the markup moves is the same false clean one level up, and that fallback takes a count so ordinary prose does not become one. + +## `build_dist.py` + +Regenerates [`.claude-plugin/fleet-skills/`][fleet-skills-dist] from [`.agents/skills/`][agents-skills], the hub's own hand-authored fleet Skills. Codex and opencode read `.agents/skills/` directly, project-local, with no install step. Claude Code scans neither that path nor any repo-root path by default, only `.claude/skills/` or a plugin's own `skills/`, so this script materializes a Claude-plugin-compatible copy for [`.claude-plugin/marketplace.json`][marketplace] to publish, nested under `.claude-plugin/` rather than a top-level `dist/`, since this repo's `.gitignore` already gives `dist/` a different, Python-build-artifact meaning. `.agents/skills/` stays the one place a skill is hand-edited, and `.claude-plugin/fleet-skills/` is generated and never hand-edited, the same discipline this fleet already applies to other derived trees. + +`--check` is the read-only mode: it exits `1` when the generated plugin was built from different source bytes than `.agents/skills/` currently holds, comparing a digest over every source file rather than a file-count or a timestamp, so a same-size edit still registers. CI runs `--check` rather than trusting a contributor to have run the generator, the same reason `spec/audit.py` exists rather than trusting a hand-carried file. + +## `skills_install.py` + +Installs the fleet's Skills for the current machine, cross-platform and idempotent, mirroring [`host-setup/agent-safety/install.py`][agent-safety-install]'s shape: `skills_install.sh` and `skills_install.ps1` are thin wrappers that locate a Python 3 interpreter and hand off, so every OS runs one tested code path. Two independent things happen on a run, since the three tools this fleet targets discover skills differently: `.agents/skills/` is materialized (not symlinked) to `$HOME/.agents/skills/`, so Codex and opencode's global scan covers every repo on the machine rather than only the one that happens to be open, and this repo's marketplace is registered with the `claude` CLI (`claude plugin marketplace add`, `claude plugin install`) so Claude Code loads the same content the other two read directly. The marketplace/plugin registration goes through the `claude` CLI's own commands rather than writing its internal `known_marketplaces.json` by hand, because that file's shape is the CLI's state, not a documented contract, and a hand-written copy risks drifting from what the CLI expects on its next release. + +`--report` reads the stamp a prior run wrote (`$HOME/.agents/skills-install-stamp.json`, naming the hub commit installed) against the current checkout and says whether the machine is current, without installing anything. A repository whose `AGENTS.md` keeps needing a rule restated is usually this: the machine was never installed, or was installed from an older commit. + +<!-- Internal --> + +[agent-safety-install]: ../host-setup/agent-safety/install.py +[agents-skills]: ../.agents/skills/README.md +[audit]: ../spec/audit.py +[copilot-instructions]: ../.github/copilot-instructions.md +[editorconfig]: ../.editorconfig +[files]: ../spec/files.json +[fleet-skills-dist]: ../.claude-plugin/fleet-skills/ +[gitattributes]: ../.gitattributes +[governance]: ../GOVERNANCE.md +[governance-hub-hosted-tooling]: ../GOVERNANCE.md#hub-hosted-tooling +[host-setup]: ../docs/host-setup.md +[host-tools]: ../spec/host-tools.json +[marketplace]: ../.claude-plugin/marketplace.json +[operations]: ../OPERATIONS.md +[prose-gate-action]: ../.github/actions/prose-gate/action.yml +[repos]: ../registry/repos.json +[section-model]: ../spec/section-model.md +[tests]: ./tests/ diff --git a/scripts/build_dist.py b/scripts/build_dist.py index ebb6cac8..7fe2e239 100755 --- a/scripts/build_dist.py +++ b/scripts/build_dist.py @@ -106,10 +106,10 @@ def expected_manifest(names): def write_plugin_manifest(names): PLUGIN_MANIFEST.parent.mkdir(parents=True, exist_ok=True) manifest = expected_manifest(names) - # CRLF, matching this repo's JSON default (.editorconfig `[*] end_of_line = crlf`). - # No LF pin applies here, since this is not a shebang-executed or shell-consumed path. + # LF, matching this repo's JSON default (.editorconfig `[*] end_of_line = lf`). + # Explicit, not the platform default: a Windows host writing plain LF (newline=None) would translate it to CRLF on write, which disagrees with this repo's LF default. PLUGIN_MANIFEST.write_text( - json.dumps(manifest, indent=2) + "\n", encoding="utf-8", newline="\r\n" + json.dumps(manifest, indent=2) + "\n", encoding="utf-8", newline="\n" ) @@ -125,10 +125,8 @@ def regenerate(): reject_symlinks(SKILLS_SRC / name) shutil.copytree(SKILLS_SRC / name, dist_skills / name) write_plugin_manifest(names) - # CRLF, same as write_plugin_manifest. - # The platform default would write plain LF on Linux. - # That disagrees with this repo's CRLF default for a path no LF pin covers. - DIGEST_STAMP.write_text(source_digest(names) + "\n", encoding="utf-8", newline="\r\n") + # LF, same as write_plugin_manifest, explicit for the same Windows-platform-default reason. + DIGEST_STAMP.write_text(source_digest(names) + "\n", encoding="utf-8", newline="\n") return names diff --git a/scripts/skills_install.ps1 b/scripts/skills_install.ps1 index 73836c26..42b1acd7 100644 --- a/scripts/skills_install.ps1 +++ b/scripts/skills_install.ps1 @@ -1,47 +1,47 @@ -# Thin wrapper: run the cross-platform installer with a Python 3.7+ (Windows). -# All logic lives in skills_install.py so every OS runs one tested code path. -# It is idempotent and safe to re-run. -# Run: scripts\skills_install.ps1 -# Or: $env:AGENTS_HOME = "C:\path"; scripts\skills_install.ps1 # override the target (testing) -$ErrorActionPreference = "Stop" -$here = Split-Path -Parent $MyInvocation.MyCommand.Path -$script = Join-Path $here "skills_install.py" - -# Try each candidate in order, version-checking it before committing to it. -# None of these launchers guarantees 3.7+ by construction. -# `py -3` and `python3` can both resolve to an old 3.6 interpreter on some systems. -# Picking the first *available* launcher and checking only that one would fail the whole install on a machine where an earlier candidate is stale but a later one is fine. -$candidates = @( - @{ Exe = "py"; Args = @("-3") }, - @{ Exe = "python3"; Args = @() }, - @{ Exe = "python"; Args = @() } -) - -$pyExe = $null -$pyArgs = @() -foreach ($c in $candidates) { - if (-not (Get-Command $c.Exe -ErrorAction SilentlyContinue)) { - continue - } - # The installer uses `from __future__ import annotations`, which needs 3.7+. - # A bare `python` may be Python 2 on some systems. - & $c.Exe @($c.Args) -c "import sys; sys.exit(0 if sys.version_info >= (3, 7) else 1)" 2>$null - if ($LASTEXITCODE -eq 0) { - $pyExe = $c.Exe - $pyArgs = $c.Args - break - } -} - -if (-not $pyExe) { - Write-Error "Python 3.7+ is required and was not found on PATH (tried py -3, python3, python)." - exit 1 -} - -# $pyArgs is splatted rather than sliced from a combined array. -# PowerShell's `1..0` range operator returns a descending @(1, 0) rather than empty. -# That would corrupt a single-element launcher's argument list. -& $pyExe @pyArgs $script @args - -# Propagate the installer's exit code - a native command's non-zero exit does not stop the script. -exit $LASTEXITCODE +# Thin wrapper: run the cross-platform installer with a Python 3.7+ (Windows). +# All logic lives in skills_install.py so every OS runs one tested code path. +# It is idempotent and safe to re-run. +# Run: scripts\skills_install.ps1 +# Or: $env:AGENTS_HOME = "C:\path"; scripts\skills_install.ps1 # override the target (testing) +$ErrorActionPreference = "Stop" +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +$script = Join-Path $here "skills_install.py" + +# Try each candidate in order, version-checking it before committing to it. +# None of these launchers guarantees 3.7+ by construction. +# `py -3` and `python3` can both resolve to an old 3.6 interpreter on some systems. +# Picking the first *available* launcher and checking only that one would fail the whole install on a machine where an earlier candidate is stale but a later one is fine. +$candidates = @( + @{ Exe = "py"; Args = @("-3") }, + @{ Exe = "python3"; Args = @() }, + @{ Exe = "python"; Args = @() } +) + +$pyExe = $null +$pyArgs = @() +foreach ($c in $candidates) { + if (-not (Get-Command $c.Exe -ErrorAction SilentlyContinue)) { + continue + } + # The installer uses `from __future__ import annotations`, which needs 3.7+. + # A bare `python` may be Python 2 on some systems. + & $c.Exe @($c.Args) -c "import sys; sys.exit(0 if sys.version_info >= (3, 7) else 1)" 2>$null + if ($LASTEXITCODE -eq 0) { + $pyExe = $c.Exe + $pyArgs = $c.Args + break + } +} + +if (-not $pyExe) { + Write-Error "Python 3.7+ is required and was not found on PATH (tried py -3, python3, python)." + exit 1 +} + +# $pyArgs is splatted rather than sliced from a combined array. +# PowerShell's `1..0` range operator returns a descending @(1, 0) rather than empty. +# That would corrupt a single-element launcher's argument list. +& $pyExe @pyArgs $script @args + +# Propagate the installer's exit code - a native command's non-zero exit does not stop the script. +exit $LASTEXITCODE diff --git a/scripts/tests/test_bootstrap.py b/scripts/tests/test_bootstrap.py index 927baa23..ab96a153 100644 --- a/scripts/tests/test_bootstrap.py +++ b/scripts/tests/test_bootstrap.py @@ -321,9 +321,8 @@ def test_every_windows_script_is_present() -> None: The exec bit is the Linux form of "this will run", and on Windows the equivalent property is the absence of a shebang: `scripts/repo_gate.py --check eol-coverage` requires git to resolve any - tracked file opening `#!` to `eol=lf`, and these files are CRLF by the `[*]` .editorconfig - default with no pin of their own. A shebang added later would fail that gate from a file nobody - would think to look at. + tracked file opening `#!` to `eol=lf` via a `.gitattributes` pin, and these files carry none of + their own. A shebang added later would fail that gate from a file nobody would think to look at. """ scripts = ( "install-skills.ps1", @@ -340,8 +339,8 @@ def test_every_windows_script_is_present() -> None: if path.is_file(): check( not path.read_bytes().startswith(b"#!"), - f"{name} opens with a shebang, which the eol-coverage gate then pins to LF, " - f"against the CRLF these files are written with", + f"{name} opens with a shebang, which the eol-coverage gate then requires a " + f"`.gitattributes` pin for", ) @@ -351,15 +350,15 @@ def test_bootstrap_ps1_is_present_and_unmarked() -> None: Kept apart from `test_every_windows_script_is_present` rather than folded into it, because `bootstrap.ps1` deliberately sits beside `bootstrap.sh` at `host-setup/`, not inside `host-setup/windows/` with the four scripts that test checks. Same reasoning as that test: the - `eol-coverage` gate pins a tracked file opening `#!` to `eol=lf`, against the CRLF this file is - written with. + `eol-coverage` gate requires a `.gitattributes` pin for any tracked file opening `#!`, and this + file carries none of its own. """ check(BOOTSTRAP_PS.is_file(), "bootstrap.ps1 is missing from host-setup") if BOOTSTRAP_PS.is_file(): check( not BOOTSTRAP_PS.read_bytes().startswith(b"#!"), - "bootstrap.ps1 opens with a shebang, which the eol-coverage gate then pins to LF, " - "against the CRLF this file is written with", + "bootstrap.ps1 opens with a shebang, which the eol-coverage gate then requires a " + "`.gitattributes` pin for", ) diff --git a/scripts/tests/test_build_dist.py b/scripts/tests/test_build_dist.py index 84cffd82..9c558a38 100755 --- a/scripts/tests/test_build_dist.py +++ b/scripts/tests/test_build_dist.py @@ -83,13 +83,16 @@ def test_a_skill_directory_without_skill_md_is_not_discovered(self) -> None: (self.skills_src / "half-written" / "notes.txt").write_text("wip", encoding="utf-8") self.assertEqual(build_dist.skill_names(), []) - def test_digest_stamp_ends_with_a_final_crlf(self) -> None: + def test_digest_stamp_is_lf_only(self) -> None: """Read as bytes, not text: a text-mode read applies universal-newline translation and - would report a plain LF ending as "\\n" too, hiding exactly the platform-default-newline - bug this asserts against.""" + would hide a stray CR. build_dist.py's explicit `newline="\\n"` is what keeps this LF on + a Windows host too; a Linux-CI runner's platform default is also LF, so this assertion + alone cannot tell "explicit" from "happened to match", only that the shape is right.""" self.make_skill("foo") build_dist.regenerate() - self.assertTrue(build_dist.DIGEST_STAMP.read_bytes().endswith(b"\r\n")) + content = build_dist.DIGEST_STAMP.read_bytes() + self.assertNotIn(b"\r", content) + self.assertTrue(content.endswith(b"\n")) def test_regenerate_copies_skill_content_and_lists_it_in_the_manifest(self) -> None: self.make_skill("foo") diff --git a/spec/audit.py b/spec/audit.py index 52a94d47..2b36b2d9 100755 --- a/spec/audit.py +++ b/spec/audit.py @@ -2385,7 +2385,7 @@ def _selftest(): # Coordination-reference scan: the hub name inside a verbatim section is exempt, outside one is not. # The first case is the real AGENTS.md shape, where the byte-locked Fleet Bootstrap block must name the hub and a repo therefore cannot clear a finding against it. - # The CRLF case matters because extract_section normalizes EOLs while carried files are CRLF on this fleet, so excision must survive that. + # The CRLF case matters because extract_section normalizes EOLs while a carried file can still arrive CRLF (an operational repo's Windows-native override, or a stale copy mid-resync), so excision must survive that. boot = "## Fleet Bootstrap\n\nThe canonical rules live in `github.com/acme/Hub`.\n" owned = "## Where the Rules Live\n\nReport a rule discrepancy to acme/Hub.\n" clean_doc = ( diff --git a/spec/divergences.json b/spec/divergences.json index a39be82b..086a862f 100644 --- a/spec/divergences.json +++ b/spec/divergences.json @@ -1,16 +1,16 @@ -{ - "$schema": "./divergences.schema.json", - "note": "Curated dispositions for known fleet divergences from the manifest canonicals - the burn-down ledger. spec/fidelity_honesty.py --report joins this against live fleet reality to write reports/divergences.md. A recorded divergence still present renders as a burn-down task with its disposition. A live divergence absent here renders as UNTRIAGED. A recorded divergence no longer live renders as resolved. Edit this file (not the generated report) and regenerate. dispositions cover per-repo divergences from a verbatim or intent canonical - a whole file, or a verbatim section whose unit key is the file path, then a space-greater-space delimiter, then the section name (for example GOVERNANCE.md > Git and Commit Rules). gaps cover files carried by the fleet but absent from spec/files.json. disposition vocabulary: re-vendor (drift-to-fix, copy the current canonical down), track (a gap to add to the manifest), accepted (a legitimate permanent divergence, no action), upstream-candidate (the downstream carries an improvement the hub should adopt, then re-vendor), investigate (recorded, decision pending), retire (the hub hosts the file and a downstream copy is deleted rather than converged, per GOVERNANCE.md 'Hub-Hosted Tooling').", - "dispositions": [ - { "path": ".editorconfig-checker.json", "repos": ["HomeAutomation-Config", "HolidayLights"], "disposition": "accepted", "reason": "Both carry a legitimate repo-specific Exclude list (HomeAutomation-Config excludes a Vantage/ subtree, HolidayLights excludes .fseq sequence files). The uniform Disable block is carried intent-equivalent. Exclude is inherently repo-local, which is why the unit is intent, not verbatim.", "tracking": null }, - { "path": ".markdownlint-cli2.jsonc", "repos": ["aiopurpleair", "PhotoCleaner", "AudioCleaner"], "disposition": "re-vendor", "reason": "Verbatim config held as a hand-modified copy rather than a past hub revision. Restore the current canonical.", "tracking": null } - ], - "gaps": [ - { "path": "repo-config/configure.sh", "disposition": "retire", "reason": "The script is hub-hosted rather than carried, per GOVERNANCE.md \"Hub-Hosted Tooling\", so it is no longer a manifest entry and a downstream copy is retired rather than re-vendored. Every copy the fleet holds is the hub's content with nothing per-repo in it, and the copies predate the payload-driven check mode, which is the drift this removes rather than converges. Delete the copy as each repo is next visited and run the hub's script against the repo instead. The carriers are not enumerated in this ledger entry, because the fleet-wide gap pass names them from live state in the generated report and a hand-kept list understated them: it read six while fifteen repos carry one. The ruleset payloads and settings.json stay carried, since those are what the repo is audited against and what its own AUDIT.md diffs the live configuration against.", "tracking": null }, - { "path": "LICENSE", "disposition": "accepted", "reason": "Each repo owns its license file. The hub does not standardize license text, so it is intentionally outside the manifest.", "tracking": null }, - { "path": "TODO.md", "disposition": "accepted", "reason": "A repo's own running backlog, which STANDUP.md section 2 names as one of the four declared destinations for repo-specific content. It is advisory and required by no selector, so the manifest deliberately does not declare it and a repo's copy is its own content rather than a carry.", "tracking": null }, - { "path": ".husky/pre-commit", "disposition": "accepted", "reason": "A path collision rather than a carry. Each repo's hook runs that repo's own language formatters, and the hub's runs the doc gates, so the seven downstream copies share the path and none of the content: LanguageTags and Utilities run language formatting and style with no Docker, PlexCleaner runs CSharpier and dotnet format, and the hub runs prose_lint.py and repo_gate.py. Verified by reading the downstream copies on 2026-08-10.", "tracking": null }, - { "path": "scripts/README.md", "disposition": "accepted", "reason": "A path collision rather than a carry. KiCadLibrary's copy documents its own KiCad tooling (common.py, verify_library.py, build_library.py) beside the scripts it describes, and shares nothing with the hub's fleet-gate documentation. Verified by reading it on 2026-08-10. scripts/ is a generic path, so a repo with its own tooling directory matches this check without carrying anything of the hub's.", "tracking": null }, - { "path": "pyproject.toml", "disposition": "investigate", "reason": "The hub gained a config-only Scripts-profile pyproject.toml in #388. Decide whether to track it (intent, appliesTo python) after confirming the python repos carry an equivalent.", "tracking": null } - ] -} +{ + "$schema": "./divergences.schema.json", + "note": "Curated dispositions for known fleet divergences from the manifest canonicals - the burn-down ledger. spec/fidelity_honesty.py --report joins this against live fleet reality to write reports/divergences.md. A recorded divergence still present renders as a burn-down task with its disposition. A live divergence absent here renders as UNTRIAGED. A recorded divergence no longer live renders as resolved. Edit this file (not the generated report) and regenerate. dispositions cover per-repo divergences from a verbatim or intent canonical - a whole file, or a verbatim section whose unit key is the file path, then a space-greater-space delimiter, then the section name (for example GOVERNANCE.md > Git and Commit Rules). gaps cover files carried by the fleet but absent from spec/files.json. disposition vocabulary: re-vendor (drift-to-fix, copy the current canonical down), track (a gap to add to the manifest), accepted (a legitimate permanent divergence, no action), upstream-candidate (the downstream carries an improvement the hub should adopt, then re-vendor), investigate (recorded, decision pending), retire (the hub hosts the file and a downstream copy is deleted rather than converged, per GOVERNANCE.md 'Hub-Hosted Tooling').", + "dispositions": [ + { "path": ".editorconfig-checker.json", "repos": ["HomeAutomation-Config", "HolidayLights"], "disposition": "accepted", "reason": "Both carry a legitimate repo-specific Exclude list (HomeAutomation-Config excludes a Vantage/ subtree, HolidayLights excludes .fseq sequence files). The uniform Disable block is carried intent-equivalent. Exclude is inherently repo-local, which is why the unit is intent, not verbatim.", "tracking": null }, + { "path": ".markdownlint-cli2.jsonc", "repos": ["aiopurpleair", "PhotoCleaner", "AudioCleaner"], "disposition": "re-vendor", "reason": "Verbatim config held as a hand-modified copy rather than a past hub revision. Restore the current canonical.", "tracking": null } + ], + "gaps": [ + { "path": "repo-config/configure.sh", "disposition": "retire", "reason": "The script is hub-hosted rather than carried, per GOVERNANCE.md \"Hub-Hosted Tooling\", so it is no longer a manifest entry and a downstream copy is retired rather than re-vendored. Every copy the fleet holds is the hub's content with nothing per-repo in it, and the copies predate the payload-driven check mode, which is the drift this removes rather than converges. Delete the copy as each repo is next visited and run the hub's script against the repo instead. The carriers are not enumerated in this ledger entry, because the fleet-wide gap pass names them from live state in the generated report and a hand-kept list understated them: it read six while fifteen repos carry one. The ruleset payloads and settings.json stay carried, since those are what the repo is audited against and what its own AUDIT.md diffs the live configuration against.", "tracking": null }, + { "path": "LICENSE", "disposition": "accepted", "reason": "Each repo owns its license file. The hub does not standardize license text, so it is intentionally outside the manifest.", "tracking": null }, + { "path": "TODO.md", "disposition": "accepted", "reason": "A repo's own running backlog, which STANDUP.md section 2 names as one of the four declared destinations for repo-specific content. It is advisory and required by no selector, so the manifest deliberately does not declare it and a repo's copy is its own content rather than a carry.", "tracking": null }, + { "path": ".husky/pre-commit", "disposition": "accepted", "reason": "A path collision rather than a carry. Each repo's hook runs that repo's own language formatters, and the hub's runs the doc gates, so the seven downstream copies share the path and none of the content: LanguageTags and Utilities run language formatting and style with no Docker, PlexCleaner runs CSharpier and dotnet format, and the hub runs prose_lint.py and repo_gate.py. Verified by reading the downstream copies on 2026-08-10.", "tracking": null }, + { "path": "scripts/README.md", "disposition": "accepted", "reason": "A path collision rather than a carry. KiCadLibrary's copy documents its own KiCad tooling (common.py, verify_library.py, build_library.py) beside the scripts it describes, and shares nothing with the hub's fleet-gate documentation. Verified by reading it on 2026-08-10. scripts/ is a generic path, so a repo with its own tooling directory matches this check without carrying anything of the hub's.", "tracking": null }, + { "path": "pyproject.toml", "disposition": "investigate", "reason": "The hub gained a config-only Scripts-profile pyproject.toml in #388. Decide whether to track it (intent, appliesTo python) after confirming the python repos carry an equivalent.", "tracking": null } + ] +} diff --git a/spec/divergences.schema.json b/spec/divergences.schema.json index 93ed3f76..c17bf251 100644 --- a/spec/divergences.schema.json +++ b/spec/divergences.schema.json @@ -1,40 +1,40 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/ptr727/ProjectTemplate/spec/divergences.schema.json", - "title": "Fleet divergence dispositions (burn-down ledger)", - "type": "object", - "additionalProperties": false, - "properties": { - "$schema": { "type": "string" }, - "note": { "type": "string" }, - "dispositions": { - "type": "array", - "items": { - "type": "object", - "required": ["path", "repos", "disposition", "reason"], - "additionalProperties": false, - "properties": { - "path": { "type": "string" }, - "repos": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, - "disposition": { "enum": ["re-vendor", "track", "accepted", "upstream-candidate", "investigate", "retire"] }, - "reason": { "type": "string" }, - "tracking": { "type": ["string", "null"] } - } - } - }, - "gaps": { - "type": "array", - "items": { - "type": "object", - "required": ["path", "disposition", "reason"], - "additionalProperties": false, - "properties": { - "path": { "type": "string" }, - "disposition": { "enum": ["re-vendor", "track", "accepted", "upstream-candidate", "investigate", "retire"] }, - "reason": { "type": "string" }, - "tracking": { "type": ["string", "null"] } - } - } - } - } -} +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/ptr727/ProjectTemplate/spec/divergences.schema.json", + "title": "Fleet divergence dispositions (burn-down ledger)", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { "type": "string" }, + "note": { "type": "string" }, + "dispositions": { + "type": "array", + "items": { + "type": "object", + "required": ["path", "repos", "disposition", "reason"], + "additionalProperties": false, + "properties": { + "path": { "type": "string" }, + "repos": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, + "disposition": { "enum": ["re-vendor", "track", "accepted", "upstream-candidate", "investigate", "retire"] }, + "reason": { "type": "string" }, + "tracking": { "type": ["string", "null"] } + } + } + }, + "gaps": { + "type": "array", + "items": { + "type": "object", + "required": ["path", "disposition", "reason"], + "additionalProperties": false, + "properties": { + "path": { "type": "string" }, + "disposition": { "enum": ["re-vendor", "track", "accepted", "upstream-candidate", "investigate", "retire"] }, + "reason": { "type": "string" }, + "tracking": { "type": ["string", "null"] } + } + } + } + } +} diff --git a/spec/fidelity-model.md b/spec/fidelity-model.md index 296ba8a5..be9cff14 100644 --- a/spec/fidelity-model.md +++ b/spec/fidelity-model.md @@ -1,47 +1,47 @@ -# Fidelity Model - -How faithfully each carried unit must survive the carry, and how that is verified. This is a hub-only doc governing the carrying machinery ([`spec/files.json`][files], [`spec/files.schema.json`][files-schema], [`spec/audit.py`][audit]) and is not carried to the fleet. It is the companion to [`spec/scope-model.md`][scope-model]: scope decides *which* repos get a unit, fidelity decides *how faithfully* they must carry it. - -## The Fixed and the Overridable - -Carried content is a class with virtual functions. The **fixed** part is the interface: when a thing is invoked, what it is named, and where it is wired. The **overridable** part is the implementation body, which a repo replaces to fit its own targets. Validation must allow the override while detecting a change to the interface or to content meant to stay fixed. Integrity is by **content hash, never a version number**. A version stamp is a claim a repo can keep while editing the body, so it is never trusted for detection. - -## The Four Fidelity Levels - -Each [`spec/files.json`][files] entry declares one `fidelity`, defaulting to `presence`. - -- **presence** - the unit exists (a file, or a Markdown section heading). The audit's baseline check. -- **intent** - carried faithfully but judged by meaning, not bytes. A downstream copy legitimately differs (a governed divergence or a paraphrase), and equivalence is a human call via `intentRef`. The audit asserts presence, plus a last-modified staleness advisory at drift: a hub canonical changing after the copy's own last commit marks the copy as possibly trailing, a hint rather than proof, and content is never judged. -- **verbatim** - byte-identical to the hub's canonical after line-ending, action-pin, and job-needs normalization. The audit content-hashes the downstream copy against canonical. It applies to a whole file, a workflow job region (a job selected by key), or a Markdown section region (a `## heading` block selected by name). The section granularity lets one file be **intent overall while a few of its sections are verbatim**. A universal rule block stays byte-identical fleet-wide even though the rest of the document is a repo-adapted paraphrase, so a stale section or a missing rule is caught while its heading still passes the presence check. -- **interface** - an overridable body that must honor a named contract. The audit checks the contract by name and wiring, never the body. - -Fidelity is a declared field defaulting to `presence`, never inferred from `whole`/`placeholders`. `.editorconfig` and `.markdownlint-cli2.jsonc` are both whole with no placeholders yet sit at opposite fidelity, because the discriminator is governance, not field shape. - -## Why Each Unit Sits Where It Does - -- **verbatim** - `.markdownlint-cli2.jsonc` (fleet-generic, no governed divergence), the `github-release` job region of the release task (the canonical orchestration a repo must not fork), and the universal rule sections of `AGENTS.md`, which are fleet-law with no repo-specific content (no SHAs, no `ptr727/<repo>` references) and where a paraphrase or a missing rule is a defect, not an adaptation. Which `AGENTS.md` sections are verbatim and which stay intent (the few that describe one repo) is defined in [section-model.md][section-model]. -- **interface** - the release, PR, and bot workflows. Their fixed contract is the job and check names plus the artifact handoff, or for a caller stub the hub task it reaches and the secrets it maps, while the leaf build jobs are owned. See [`WORKFLOW.md`][workflow] "Two Layers: Orchestration vs Build" and [`docs/reusable-workflows.md`][reusable-workflows]. -- **intent** - `.editorconfig` and `.gitattributes` (the `[*] end_of_line` default and path pins vary by platform), `cspell.json` (the words list and file scope vary), `CODESTYLE.md` / `WORKFLOW.md` / `AUDIT.md` / `.github/copilot-instructions.md` (carried docs judged by meaning), and the ruleset payloads (whose live state is diffed separately). -- **presence** - `README.md`, `HISTORY.md`, `.gitignore`, and the per-repo config that only needs to exist. - -## The Workflow Override Seam Contract - -The fixed interface of a workflow is stated in [`WORKFLOW.md`][workflow] ("Two Layers: Orchestration vs Build" and "The Seam Contract") and [`GOVERNANCE.md`][governance] ("Workflow YAML Conventions"), and the `interface` check enforces it by name and structure: the ruleset-bound required check `name: Check pull request workflow status job`, the `github-release` and `get-version` job keys, the `release-asset-<branch>-<target>` artifact-name handoff, and that `github-release` collects assets by `pattern:` / `merge-multiple:` and never by an `artifact-ids:` that names a build job's output. A repo owns the leaf `build-<target>-task` job list, its `needs` targets, and its paths-filter, and none of those are checked. - -## Normalization - -A verbatim check compares content by hash after **line-ending, action-pin, and job-needs normalization**. EOL variance is governed by the line-ending rules, a `uses: <action>@<sha>` pin (with its trailing `# vN` comment) is Dependabot-owned and bumped per repo, and a job's `needs:` list is pruned per repo to its vendored targets (an unvendored name fails the workflow to load), so all three are governed drift rather than a fidelity deviation. This keeps a verbatim workflow job region (the `github-release` job) from flagging on a routine action bump or a legitimate needs-prune while still catching a real structural fork. It does **not** mask placeholders: a verbatim unit carries none. The files that declare a `placeholders` list (for example `.github/copilot-instructions.md` with `<owner>`, `<repo>`, `<N>`) are fidelity `intent`, judged by hand and never hashed. Masking could not serve a hash anyway. A downstream copy holds the substituted value (`ptr727`), not the token (`<owner>`), so masking the token in the canonical alone would guarantee a mismatch. A verbatim unit that ever needed a per-repo substitution would require template-matching (the canonical as a pattern, the copy as an instance), not this content hash. None does today. - -## Stale Versus Modified - -A verbatim mismatch is one of two things, told apart **by hash, not by a version**. The audit hashes each past revision of the hub's canonical from its own git history. If the downstream copy matches a **past** canonical revision, the base advanced and the copy is **stale**, so re-vendor it. If it matches **no** revision the base ever produced, the repo **modified fixed content**, so review it. A version stamp could claim to be current while being neither, so it is demoted to a human-facing label and never consulted for integrity. - -<!-- Repo --> -[audit]: ./audit.py -[files]: ./files.json -[files-schema]: ./files.schema.json -[governance]: ../GOVERNANCE.md -[reusable-workflows]: ../docs/reusable-workflows.md -[scope-model]: ./scope-model.md -[section-model]: ./section-model.md -[workflow]: ../WORKFLOW.md +# Fidelity Model + +How faithfully each carried unit must survive the carry, and how that is verified. This is a hub-only doc governing the carrying machinery ([`spec/files.json`][files], [`spec/files.schema.json`][files-schema], [`spec/audit.py`][audit]) and is not carried to the fleet. It is the companion to [`spec/scope-model.md`][scope-model]: scope decides *which* repos get a unit, fidelity decides *how faithfully* they must carry it. + +## The Fixed and the Overridable + +Carried content is a class with virtual functions. The **fixed** part is the interface: when a thing is invoked, what it is named, and where it is wired. The **overridable** part is the implementation body, which a repo replaces to fit its own targets. Validation must allow the override while detecting a change to the interface or to content meant to stay fixed. Integrity is by **content hash, never a version number**. A version stamp is a claim a repo can keep while editing the body, so it is never trusted for detection. + +## The Four Fidelity Levels + +Each [`spec/files.json`][files] entry declares one `fidelity`, defaulting to `presence`. + +- **presence** - the unit exists (a file, or a Markdown section heading). The audit's baseline check. +- **intent** - carried faithfully but judged by meaning, not bytes. A downstream copy legitimately differs (a governed divergence or a paraphrase), and equivalence is a human call via `intentRef`. The audit asserts presence, plus a last-modified staleness advisory at drift: a hub canonical changing after the copy's own last commit marks the copy as possibly trailing, a hint rather than proof, and content is never judged. +- **verbatim** - byte-identical to the hub's canonical after line-ending, action-pin, and job-needs normalization. The audit content-hashes the downstream copy against canonical. It applies to a whole file, a workflow job region (a job selected by key), or a Markdown section region (a `## heading` block selected by name). The section granularity lets one file be **intent overall while a few of its sections are verbatim**. A universal rule block stays byte-identical fleet-wide even though the rest of the document is a repo-adapted paraphrase, so a stale section or a missing rule is caught while its heading still passes the presence check. +- **interface** - an overridable body that must honor a named contract. The audit checks the contract by name and wiring, never the body. + +Fidelity is a declared field defaulting to `presence`, never inferred from `whole`/`placeholders`. `.editorconfig` and `.markdownlint-cli2.jsonc` are both whole with no placeholders yet sit at opposite fidelity, because the discriminator is governance, not field shape. + +## Why Each Unit Sits Where It Does + +- **verbatim** - `.markdownlint-cli2.jsonc` (fleet-generic, no governed divergence), the `github-release` job region of the release task (the canonical orchestration a repo must not fork), and the universal rule sections of `AGENTS.md`, which are fleet-law with no repo-specific content (no SHAs, no `ptr727/<repo>` references) and where a paraphrase or a missing rule is a defect, not an adaptation. Which `AGENTS.md` sections are verbatim and which stay intent (the few that describe one repo) is defined in [section-model.md][section-model]. +- **interface** - the release, PR, and bot workflows. Their fixed contract is the job and check names plus the artifact handoff, or for a caller stub the hub task it reaches and the secrets it maps, while the leaf build jobs are owned. See [`WORKFLOW.md`][workflow] "Two Layers: Orchestration vs Build" and [`docs/reusable-workflows.md`][reusable-workflows]. +- **intent** - `.editorconfig` and `.gitattributes` (the `[*] end_of_line` default and path pins vary by platform), `cspell.json` (the words list and file scope vary), `CODESTYLE.md` / `WORKFLOW.md` / `AUDIT.md` / `.github/copilot-instructions.md` (carried docs judged by meaning), and the ruleset payloads (whose live state is diffed separately). +- **presence** - `README.md`, `HISTORY.md`, `.gitignore`, and the per-repo config that only needs to exist. + +## The Workflow Override Seam Contract + +The fixed interface of a workflow is stated in [`WORKFLOW.md`][workflow] ("Two Layers: Orchestration vs Build" and "The Seam Contract") and [`GOVERNANCE.md`][governance] ("Workflow YAML Conventions"), and the `interface` check enforces it by name and structure: the ruleset-bound required check `name: Check pull request workflow status job`, the `github-release` and `get-version` job keys, the `release-asset-<branch>-<target>` artifact-name handoff, and that `github-release` collects assets by `pattern:` / `merge-multiple:` and never by an `artifact-ids:` that names a build job's output. A repo owns the leaf `build-<target>-task` job list, its `needs` targets, and its paths-filter, and none of those are checked. + +## Normalization + +A verbatim check compares content by hash after **line-ending, action-pin, and job-needs normalization**. EOL variance is governed by the line-ending rules, a `uses: <action>@<sha>` pin (with its trailing `# vN` comment) is Dependabot-owned and bumped per repo, and a job's `needs:` list is pruned per repo to its vendored targets (an unvendored name fails the workflow to load), so all three are governed drift rather than a fidelity deviation. This keeps a verbatim workflow job region (the `github-release` job) from flagging on a routine action bump or a legitimate needs-prune while still catching a real structural fork. It does **not** mask placeholders: a verbatim unit carries none. The files that declare a `placeholders` list (for example `.github/copilot-instructions.md` with `<owner>`, `<repo>`, `<N>`) are fidelity `intent`, judged by hand and never hashed. Masking could not serve a hash anyway. A downstream copy holds the substituted value (`ptr727`), not the token (`<owner>`), so masking the token in the canonical alone would guarantee a mismatch. A verbatim unit that ever needed a per-repo substitution would require template-matching (the canonical as a pattern, the copy as an instance), not this content hash. None does today. + +## Stale Versus Modified + +A verbatim mismatch is one of two things, told apart **by hash, not by a version**. The audit hashes each past revision of the hub's canonical from its own git history. If the downstream copy matches a **past** canonical revision, the base advanced and the copy is **stale**, so re-vendor it. If it matches **no** revision the base ever produced, the repo **modified fixed content**, so review it. A version stamp could claim to be current while being neither, so it is demoted to a human-facing label and never consulted for integrity. + +<!-- Repo --> +[audit]: ./audit.py +[files]: ./files.json +[files-schema]: ./files.schema.json +[governance]: ../GOVERNANCE.md +[reusable-workflows]: ../docs/reusable-workflows.md +[scope-model]: ./scope-model.md +[section-model]: ./section-model.md +[workflow]: ../WORKFLOW.md diff --git a/spec/fidelity_honesty.py b/spec/fidelity_honesty.py index 8d10593f..8e7c694a 100755 --- a/spec/fidelity_honesty.py +++ b/spec/fidelity_honesty.py @@ -389,10 +389,10 @@ def main(): if report_mode: content = render_report(spreads, promote, gaps, unreadable, load_ledger()) - # CRLF matches the fleet default, since reports/*.md is CRLF. + # LF matches the fleet default, since reports/*.md is LF. # Bytes are written so the local platform does not re-translate them. # Git dates the file, so no timestamp is embedded, which would churn on every regeneration. - (audit.ROOT / REPORT_PATH).write_bytes(content.replace("\n", "\r\n").encode("utf-8")) + (audit.ROOT / REPORT_PATH).write_bytes(content.encode("utf-8")) print(f"Wrote {REPORT_PATH} ({len(content.splitlines())} lines)") return 0 diff --git a/spec/files.json b/spec/files.json index 11aa7dab..7815b6dc 100644 --- a/spec/files.json +++ b/spec/files.json @@ -1,43 +1,43 @@ -{ - "$schema": "./files.schema.json", - "note": "The standardization baseline: files and sections a fleet repo is expected to carry, and their intent authority. The audit mechanically checks presence (letter). Equivalence (intent) is judged by hand, and a section for an absent language or target is N/A. Each entry, and each section, carries an appliesTo selector - see spec/scope-model.md for the scope model and selector vocabulary. Each entry also has a fidelity (presence by default, or intent, verbatim, interface) governing how faithfully the content is checked - see spec/fidelity-model.md. The per-section fidelity of AGENTS.md and GOVERNANCE.md, which sections are verbatim fleet-law and which are repo-specific, is defined in spec/section-model.md.", - "baseline": [ - { "path": "AGENTS.md", "fidelity": "intent", "sections": [{ "name": "Fleet Bootstrap", "fidelity": "verbatim" }, - { "name": "Context and Delegation Discipline", "fidelity": "verbatim" }, { "name": "Where the Rules Live", "fidelity": "verbatim" }], "intentRef": "AGENTS.md", "appliesTo": "*" }, - { "path": "GOVERNANCE.md", "fidelity": "intent", "sections": [{ "name": "Foundational Principles", "fidelity": "verbatim" }, { "name": "Durable Knowledge and Self-Improvement", "fidelity": "verbatim" }, { "name": "Repository Boundaries and Write Safety", "fidelity": "verbatim" }, { "name": "Representative Data in Agent-Authored Text", "fidelity": "verbatim" }, { "name": "Git and Commit Rules", "fidelity": "verbatim" }, { "name": "Branching Model", "fidelity": "verbatim" }, { "name": "Release Model", "fidelity": "verbatim" }, { "name": "Operational Repositories", "fidelity": "verbatim" }, { "name": "Hub-Hosted Tooling", "fidelity": "verbatim" }, { "name": "Pull Request Title and Commit Message Conventions", "fidelity": "verbatim" }, { "name": "Documentation Style Conventions", "fidelity": "verbatim" }, { "name": "Verification Discipline", "fidelity": "verbatim" }, { "name": "PR Review Etiquette", "fidelity": "verbatim" }, { "name": "Communicating with the User", "fidelity": "verbatim" }, { "name": "Workflow YAML Conventions", "fidelity": "verbatim" }, { "name": "Supported Development Platforms", "fidelity": "verbatim" }, { "name": "Devcontainer", "fidelity": "intent" }, { "name": "Editor and Tasks", "fidelity": "verbatim" }, { "name": "Repository Details", "fidelity": "verbatim" }, { "name": "Repository Layout", "fidelity": "intent" }], "intentRef": "GOVERNANCE.md", "appliesTo": "*" }, - { "path": "CODESTYLE.md", "fidelity": "intent", "whole": true, "placeholders": ["InternalsVisibleTo project names"], "intentRef": "CODESTYLE.md", "appliesTo": "*" }, - { "path": "WORKFLOW.md", "fidelity": "intent", "whole": true, "intentRef": "WORKFLOW.md", "appliesTo": "*" }, - { "path": "README.md", "appliesTo": "*" }, - { "path": "HISTORY.md", "appliesTo": "*" }, - { "path": "OPERATIONS.md", "appliesTo": "*" }, - { "path": ".github/copilot-instructions.md", "fidelity": "intent", "whole": true, "sections": ["Commit Messages and Pull Request Titles", "Reviewing Carried Fleet Content", "GitHub Copilot Review Runbook"], "placeholders": ["<owner>", "<repo>", "<N>"], "appliesTo": "*" }, - { "path": ".editorconfig", "fidelity": "intent", "whole": true, "intentRef": "GOVERNANCE.md#line-endings", "appliesTo": "*" }, - { "path": ".editorconfig-checker.json", "fidelity": "intent", "whole": true, "intentRef": "GOVERNANCE.md#line-endings", "appliesTo": "*" }, - { "path": ".gitattributes", "fidelity": "intent", "whole": true, "intentRef": "GOVERNANCE.md#line-endings", "appliesTo": "*" }, - { "path": ".markdownlint-cli2.jsonc", "fidelity": "verbatim", "whole": true, "appliesTo": "*" }, - { "path": "cspell.json", "fidelity": "intent", "whole": true, "appliesTo": "*" }, - { "path": ".gitignore", "appliesTo": "*" }, - { "path": "host-tools.json", "appliesTo": "*" }, - { "path": "version.json", "fidelity": "intent", "intentRef": "WORKFLOW.md#d3---versioning-and-classification", "appliesTo": "*" }, - { "path": "repo-config/develop.json", "fidelity": "verbatim", "whole": true, "appliesTo": ["release"] }, - { "path": "repo-config/operational/develop.json", "fidelity": "verbatim", "whole": true, "appliesTo": ["operational"] }, - { "path": "repo-config/main.json", "fidelity": "verbatim", "whole": true, "appliesTo": "*" }, - { "path": "repo-config/README.md", "fidelity": "intent", "whole": true, "intentRef": "repo-config/README.md", "appliesTo": "*" }, - { "path": "repo-config/settings.json", "fidelity": "intent", "whole": true, "intentRef": "repo-config/README.md", "appliesTo": "*" }, - { "path": "AUDIT.md", "fidelity": "intent", "intentRef": "docs/repo-config-carry.md", "appliesTo": "*" }, - { "path": "spec/secrets.json", "fidelity": "intent", "intentRef": "docs/repo-config-carry.md", "appliesTo": "*" }, - { "path": ".github/dependabot.yml", "appliesTo": "*" }, - { "path": ".github/workflows/test-pull-request.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["check-workflow-status"], "requiredCheckName": "Check pull request workflow status job" }, "intentRef": "GOVERNANCE.md#workflow-yaml-conventions", "appliesTo": "*" }, - { "path": ".github/workflows/validate-task.yml", "fidelity": "intent", "intentRef": "WORKFLOW.md#d1---pr-fast-feedback-smoke", "appliesTo": "*" }, - { "path": ".github/workflows/publish-release.yml", "fidelity": "intent", "intentRef": "WORKFLOW.md#d4---release--publish", "appliesTo": ["two-phase", "dispatch-only", "publish-on-merge"] }, - { "path": ".github/workflows/merge-bot-pull-request.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["merge-bot"], "requireTokensInJob": { "merge-bot": ["merge-bot-task.yml", "CODEGEN_APP_CLIENT_ID", "CODEGEN_APP_PRIVATE_KEY"] } }, "intentRef": "WORKFLOW.md#d8---bots--automation", "appliesTo": "*" }, - { "path": ".github/workflows/build-release-task.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["get-version", "validate-release", "github-release"], "artifactNameToken": "release-asset-", "requireTokensInJob": { "github-release": ["pattern:", "merge-multiple:"] }, "forbidTokensInJob": { "github-release": ["artifact-ids:"] }, "verbatimJobs": ["github-release"] }, "reference": "catalog/snippets/workflows/build-release-task.yml", "intentRef": "GOVERNANCE.md#release-model", "appliesTo": ["csharp", "console", "docker", "nuget", "pypi", "eda"] }, - { "path": ".github/workflows/deploy-site.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["assert-ref", "validate", "deploy"], "requireTokensInJob": { "deploy": ["deploy-site-task.yml", "secrets: inherit"] } }, "reference": "catalog/snippets/workflows/deploy-site.yml", "intentRef": "WORKFLOW.md#d4---release--publish", "appliesTo": ["hugo"] }, - { "path": ".github/workflows/deploy-site-task.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["assert-environment", "deploy"], "requireTokensInJob": { "deploy": ["environment:"] } }, "reference": "catalog/snippets/workflows/deploy-site-task.yml", "intentRef": "WORKFLOW.md#d4---release--publish", "appliesTo": ["hugo"] }, - { "path": ".vscode/tasks.json", "sections": ["clean-compile task group"], "reference": "catalog/snippets/configs/vscode-tasks.json", "appliesTo": ["csharp"] }, - { "path": ".vscode/tasks.json", "sections": ["clean-compile task group"], "reference": "catalog/snippets/configs/vscode-tasks-python.json", "appliesTo": ["python"] }, - { "path": "codecov.yml", "fidelity": "intent", "reference": "catalog/snippets/configs/codecov.yml", "intentRef": "WORKFLOW.md", "appliesTo": ["csharp", "python"] }, - { "path": ".dockerignore", "appliesTo": ["docker"] }, - { "path": "Docker/README.md", "reference": "catalog/snippets/configs/docker-hub-readme.md", "appliesTo": ["docker"] } - ] -} +{ + "$schema": "./files.schema.json", + "note": "The standardization baseline: files and sections a fleet repo is expected to carry, and their intent authority. The audit mechanically checks presence (letter). Equivalence (intent) is judged by hand, and a section for an absent language or target is N/A. Each entry, and each section, carries an appliesTo selector - see spec/scope-model.md for the scope model and selector vocabulary. Each entry also has a fidelity (presence by default, or intent, verbatim, interface) governing how faithfully the content is checked - see spec/fidelity-model.md. The per-section fidelity of AGENTS.md and GOVERNANCE.md, which sections are verbatim fleet-law and which are repo-specific, is defined in spec/section-model.md.", + "baseline": [ + { "path": "AGENTS.md", "fidelity": "intent", "sections": [{ "name": "Fleet Bootstrap", "fidelity": "verbatim" }, + { "name": "Context and Delegation Discipline", "fidelity": "verbatim" }, { "name": "Where the Rules Live", "fidelity": "verbatim" }], "intentRef": "AGENTS.md", "appliesTo": "*" }, + { "path": "GOVERNANCE.md", "fidelity": "intent", "sections": [{ "name": "Foundational Principles", "fidelity": "verbatim" }, { "name": "Durable Knowledge and Self-Improvement", "fidelity": "verbatim" }, { "name": "Repository Boundaries and Write Safety", "fidelity": "verbatim" }, { "name": "Representative Data in Agent-Authored Text", "fidelity": "verbatim" }, { "name": "Git and Commit Rules", "fidelity": "verbatim" }, { "name": "Branching Model", "fidelity": "verbatim" }, { "name": "Release Model", "fidelity": "verbatim" }, { "name": "Operational Repositories", "fidelity": "verbatim" }, { "name": "Hub-Hosted Tooling", "fidelity": "verbatim" }, { "name": "Pull Request Title and Commit Message Conventions", "fidelity": "verbatim" }, { "name": "Documentation Style Conventions", "fidelity": "verbatim" }, { "name": "Verification Discipline", "fidelity": "verbatim" }, { "name": "PR Review Etiquette", "fidelity": "verbatim" }, { "name": "Communicating with the User", "fidelity": "verbatim" }, { "name": "Workflow YAML Conventions", "fidelity": "verbatim" }, { "name": "Supported Development Platforms", "fidelity": "verbatim" }, { "name": "Devcontainer", "fidelity": "intent" }, { "name": "Editor and Tasks", "fidelity": "verbatim" }, { "name": "Repository Details", "fidelity": "verbatim" }, { "name": "Repository Layout", "fidelity": "intent" }], "intentRef": "GOVERNANCE.md", "appliesTo": "*" }, + { "path": "CODESTYLE.md", "fidelity": "intent", "whole": true, "placeholders": ["InternalsVisibleTo project names"], "intentRef": "CODESTYLE.md", "appliesTo": "*" }, + { "path": "WORKFLOW.md", "fidelity": "intent", "whole": true, "intentRef": "WORKFLOW.md", "appliesTo": "*" }, + { "path": "README.md", "appliesTo": "*" }, + { "path": "HISTORY.md", "appliesTo": "*" }, + { "path": "OPERATIONS.md", "appliesTo": "*" }, + { "path": ".github/copilot-instructions.md", "fidelity": "intent", "whole": true, "sections": ["Commit Messages and Pull Request Titles", "Reviewing Carried Fleet Content", "GitHub Copilot Review Runbook"], "placeholders": ["<owner>", "<repo>", "<N>"], "appliesTo": "*" }, + { "path": ".editorconfig", "fidelity": "intent", "whole": true, "intentRef": "GOVERNANCE.md#line-endings", "appliesTo": "*" }, + { "path": ".editorconfig-checker.json", "fidelity": "intent", "whole": true, "intentRef": "GOVERNANCE.md#line-endings", "appliesTo": "*" }, + { "path": ".gitattributes", "fidelity": "intent", "whole": true, "intentRef": "GOVERNANCE.md#line-endings", "appliesTo": "*" }, + { "path": ".markdownlint-cli2.jsonc", "fidelity": "verbatim", "whole": true, "appliesTo": "*" }, + { "path": "cspell.json", "fidelity": "intent", "whole": true, "appliesTo": "*" }, + { "path": ".gitignore", "appliesTo": "*" }, + { "path": "host-tools.json", "appliesTo": "*" }, + { "path": "version.json", "fidelity": "intent", "intentRef": "WORKFLOW.md#d3---versioning-and-classification", "appliesTo": "*" }, + { "path": "repo-config/develop.json", "fidelity": "verbatim", "whole": true, "appliesTo": ["release"] }, + { "path": "repo-config/operational/develop.json", "fidelity": "verbatim", "whole": true, "appliesTo": ["operational"] }, + { "path": "repo-config/main.json", "fidelity": "verbatim", "whole": true, "appliesTo": "*" }, + { "path": "repo-config/README.md", "fidelity": "intent", "whole": true, "intentRef": "repo-config/README.md", "appliesTo": "*" }, + { "path": "repo-config/settings.json", "fidelity": "intent", "whole": true, "intentRef": "repo-config/README.md", "appliesTo": "*" }, + { "path": "AUDIT.md", "fidelity": "intent", "intentRef": "docs/repo-config-carry.md", "appliesTo": "*" }, + { "path": "spec/secrets.json", "fidelity": "intent", "intentRef": "docs/repo-config-carry.md", "appliesTo": "*" }, + { "path": ".github/dependabot.yml", "appliesTo": "*" }, + { "path": ".github/workflows/test-pull-request.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["check-workflow-status"], "requiredCheckName": "Check pull request workflow status job" }, "intentRef": "GOVERNANCE.md#workflow-yaml-conventions", "appliesTo": "*" }, + { "path": ".github/workflows/validate-task.yml", "fidelity": "intent", "intentRef": "WORKFLOW.md#d1---pr-fast-feedback-smoke", "appliesTo": "*" }, + { "path": ".github/workflows/publish-release.yml", "fidelity": "intent", "intentRef": "WORKFLOW.md#d4---release--publish", "appliesTo": ["two-phase", "dispatch-only", "publish-on-merge"] }, + { "path": ".github/workflows/merge-bot-pull-request.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["merge-bot"], "requireTokensInJob": { "merge-bot": ["merge-bot-task.yml", "CODEGEN_APP_CLIENT_ID", "CODEGEN_APP_PRIVATE_KEY"] } }, "intentRef": "WORKFLOW.md#d8---bots--automation", "appliesTo": "*" }, + { "path": ".github/workflows/build-release-task.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["get-version", "validate-release", "github-release"], "artifactNameToken": "release-asset-", "requireTokensInJob": { "github-release": ["pattern:", "merge-multiple:"] }, "forbidTokensInJob": { "github-release": ["artifact-ids:"] }, "verbatimJobs": ["github-release"] }, "reference": "catalog/snippets/workflows/build-release-task.yml", "intentRef": "GOVERNANCE.md#release-model", "appliesTo": ["csharp", "console", "docker", "nuget", "pypi", "eda"] }, + { "path": ".github/workflows/deploy-site.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["assert-ref", "validate", "deploy"], "requireTokensInJob": { "deploy": ["deploy-site-task.yml", "secrets: inherit"] } }, "reference": "catalog/snippets/workflows/deploy-site.yml", "intentRef": "WORKFLOW.md#d4---release--publish", "appliesTo": ["hugo"] }, + { "path": ".github/workflows/deploy-site-task.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["assert-environment", "deploy"], "requireTokensInJob": { "deploy": ["environment:"] } }, "reference": "catalog/snippets/workflows/deploy-site-task.yml", "intentRef": "WORKFLOW.md#d4---release--publish", "appliesTo": ["hugo"] }, + { "path": ".vscode/tasks.json", "sections": ["clean-compile task group"], "reference": "catalog/snippets/configs/vscode-tasks.json", "appliesTo": ["csharp"] }, + { "path": ".vscode/tasks.json", "sections": ["clean-compile task group"], "reference": "catalog/snippets/configs/vscode-tasks-python.json", "appliesTo": ["python"] }, + { "path": "codecov.yml", "fidelity": "intent", "reference": "catalog/snippets/configs/codecov.yml", "intentRef": "WORKFLOW.md", "appliesTo": ["csharp", "python"] }, + { "path": ".dockerignore", "appliesTo": ["docker"] }, + { "path": "Docker/README.md", "reference": "catalog/snippets/configs/docker-hub-readme.md", "appliesTo": ["docker"] } + ] +} diff --git a/spec/files.schema.json b/spec/files.schema.json index 5e7bc75e..5786890c 100644 --- a/spec/files.schema.json +++ b/spec/files.schema.json @@ -1,58 +1,58 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/ptr727/ProjectTemplate/spec/files.schema.json", - "title": "Standardization baseline files", - "type": "object", - "required": ["baseline"], - "properties": { - "$schema": { "type": "string" }, - "note": { "type": "string" }, - "baseline": { - "type": "array", - "items": { - "type": "object", - "required": ["path"], - "additionalProperties": false, - "properties": { - "path": { "type": "string" }, - "whole": { "type": "boolean" }, - "sections": { - "type": "array", - "items": { - "oneOf": [ - { "type": "string" }, - { - "type": "object", - "required": ["name"], - "additionalProperties": false, - "properties": { - "name": { "type": "string", "minLength": 1 }, - "appliesTo": { "type": ["string", "array"], "items": { "type": "string" }, "minItems": 1 }, - "fidelity": { "enum": ["intent", "verbatim"] } - } - } - ] - } - }, - "placeholders": { "type": "array", "items": { "type": "string" } }, - "reference": { "type": "string" }, - "intentRef": { "type": "string" }, - "appliesTo": { "type": ["string", "array"], "items": { "type": "string" }, "minItems": 1 }, - "fidelity": { "enum": ["presence", "intent", "verbatim", "interface"] }, - "contract": { - "type": "object", - "additionalProperties": false, - "properties": { - "requiredJobKeys": { "type": "array", "items": { "type": "string" } }, - "requiredCheckName": { "type": "string" }, - "artifactNameToken": { "type": "string" }, - "requireTokensInJob": { "type": "object", "additionalProperties": { "type": "array", "items": { "type": "string" } } }, - "forbidTokensInJob": { "type": "object", "additionalProperties": { "type": "array", "items": { "type": "string" } } }, - "verbatimJobs": { "type": "array", "items": { "type": "string" } } - } - } - } - } - } - } -} +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/ptr727/ProjectTemplate/spec/files.schema.json", + "title": "Standardization baseline files", + "type": "object", + "required": ["baseline"], + "properties": { + "$schema": { "type": "string" }, + "note": { "type": "string" }, + "baseline": { + "type": "array", + "items": { + "type": "object", + "required": ["path"], + "additionalProperties": false, + "properties": { + "path": { "type": "string" }, + "whole": { "type": "boolean" }, + "sections": { + "type": "array", + "items": { + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { "type": "string", "minLength": 1 }, + "appliesTo": { "type": ["string", "array"], "items": { "type": "string" }, "minItems": 1 }, + "fidelity": { "enum": ["intent", "verbatim"] } + } + } + ] + } + }, + "placeholders": { "type": "array", "items": { "type": "string" } }, + "reference": { "type": "string" }, + "intentRef": { "type": "string" }, + "appliesTo": { "type": ["string", "array"], "items": { "type": "string" }, "minItems": 1 }, + "fidelity": { "enum": ["presence", "intent", "verbatim", "interface"] }, + "contract": { + "type": "object", + "additionalProperties": false, + "properties": { + "requiredJobKeys": { "type": "array", "items": { "type": "string" } }, + "requiredCheckName": { "type": "string" }, + "artifactNameToken": { "type": "string" }, + "requireTokensInJob": { "type": "object", "additionalProperties": { "type": "array", "items": { "type": "string" } } }, + "forbidTokensInJob": { "type": "object", "additionalProperties": { "type": "array", "items": { "type": "string" } } }, + "verbatimJobs": { "type": "array", "items": { "type": "string" } } + } + } + } + } + } + } +} diff --git a/spec/host-tools-local.schema.json b/spec/host-tools-local.schema.json index 587ae0b2..af5d364e 100644 --- a/spec/host-tools-local.schema.json +++ b/spec/host-tools-local.schema.json @@ -1,55 +1,55 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/ptr727/ProjectTemplate/spec/host-tools-local.schema.json", - "title": "Repository host tool overlay", - "description": "A repository's own root host-tools.json, which scripts/host_gate.py layers over the fleet declaration in spec/host-tools.json. This is a different format from that file and needs its own schema rather than borrowing it, in three ways. An empty tools list is the ordinary case, because a repository with nothing to add still carries the file, where an empty fleet declaration would mean no host contract at all. An entry may be partial, carrying a name plus only the fields it changes, which is what lets a repository raise one floor without restating a whole tool, where the fleet declaration requires every field of every entry. Only name is required here, since an entry that names no tool cannot be matched against one. And an omitted required field means inherit rather than true: scripts/host_gate.py applies only the fields an entry carries, so a default declared here would tell a reader the opposite of what the merge does, which is why this file states none where the fleet declaration states true. Every other constraint matches the fleet schema field for field, and a test asserts that rather than trusting this sentence, because this file was hand-written from that one and drifted on two fields before the test existed. The gate rather than this schema is the authority on what an overlay may do: layering is tighten-only, so an entry may add a tool, raise a floor, or turn an optional tool required, and may not lower a floor or turn a required tool optional, and a rejected relaxation is reported rather than dropped. A schema cannot express that, because it compares nothing against the fleet declaration.", - "type": "object", - "required": ["tools"], - "additionalProperties": false, - "properties": { - "$schema": { "type": "string" }, - "note": { "type": "string" }, - "tools": { - "type": "array", - "minItems": 0, - "items": { "$ref": "#/$defs/override" } - } - }, - "$defs": { - "override": { - "type": "object", - "required": ["name"], - "additionalProperties": false, - "properties": { - "name": { "type": "string", "minLength": 1 }, - "required": { "type": "boolean" }, - "probes": { - "type": "array", - "minItems": 1, - "items": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } } - }, - "pattern": { "type": "string", "minLength": 1 }, - "minimum": { "type": ["string", "null"], "pattern": "^\\d+(\\.\\d+)*$" }, - "why": { "type": "string", "minLength": 1 }, - "source": { - "type": "object", - "additionalProperties": false, - "properties": { - "linux": { "type": "string", "minLength": 1 }, - "macos": { "type": "string", "minLength": 1 }, - "windows": { "type": "string", "minLength": 1 } - } - }, - "remedy": { - "type": "object", - "additionalProperties": false, - "properties": { - "linux": { "type": "string", "minLength": 1 }, - "macos": { "type": "string", "minLength": 1 }, - "windows": { "type": "string", "minLength": 1 } - } - } - } - } - } -} +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/ptr727/ProjectTemplate/spec/host-tools-local.schema.json", + "title": "Repository host tool overlay", + "description": "A repository's own root host-tools.json, which scripts/host_gate.py layers over the fleet declaration in spec/host-tools.json. This is a different format from that file and needs its own schema rather than borrowing it, in three ways. An empty tools list is the ordinary case, because a repository with nothing to add still carries the file, where an empty fleet declaration would mean no host contract at all. An entry may be partial, carrying a name plus only the fields it changes, which is what lets a repository raise one floor without restating a whole tool, where the fleet declaration requires every field of every entry. Only name is required here, since an entry that names no tool cannot be matched against one. And an omitted required field means inherit rather than true: scripts/host_gate.py applies only the fields an entry carries, so a default declared here would tell a reader the opposite of what the merge does, which is why this file states none where the fleet declaration states true. Every other constraint matches the fleet schema field for field, and a test asserts that rather than trusting this sentence, because this file was hand-written from that one and drifted on two fields before the test existed. The gate rather than this schema is the authority on what an overlay may do: layering is tighten-only, so an entry may add a tool, raise a floor, or turn an optional tool required, and may not lower a floor or turn a required tool optional, and a rejected relaxation is reported rather than dropped. A schema cannot express that, because it compares nothing against the fleet declaration.", + "type": "object", + "required": ["tools"], + "additionalProperties": false, + "properties": { + "$schema": { "type": "string" }, + "note": { "type": "string" }, + "tools": { + "type": "array", + "minItems": 0, + "items": { "$ref": "#/$defs/override" } + } + }, + "$defs": { + "override": { + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { "type": "string", "minLength": 1 }, + "required": { "type": "boolean" }, + "probes": { + "type": "array", + "minItems": 1, + "items": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } } + }, + "pattern": { "type": "string", "minLength": 1 }, + "minimum": { "type": ["string", "null"], "pattern": "^\\d+(\\.\\d+)*$" }, + "why": { "type": "string", "minLength": 1 }, + "source": { + "type": "object", + "additionalProperties": false, + "properties": { + "linux": { "type": "string", "minLength": 1 }, + "macos": { "type": "string", "minLength": 1 }, + "windows": { "type": "string", "minLength": 1 } + } + }, + "remedy": { + "type": "object", + "additionalProperties": false, + "properties": { + "linux": { "type": "string", "minLength": 1 }, + "macos": { "type": "string", "minLength": 1 }, + "windows": { "type": "string", "minLength": 1 } + } + } + } + } + } +} diff --git a/spec/host-tools.json b/spec/host-tools.json index bd3d4916..187e0a9a 100644 --- a/spec/host-tools.json +++ b/spec/host-tools.json @@ -1,121 +1,121 @@ -{ - "$schema": "./host-tools.schema.json", - "note": "The host contract in docs/host-setup.md, as data a gate can read. docs/host-setup.md states which tools a host needs and which repo procedure stops working without each one; this file adds the version floors, and each floor records the defect it encodes rather than a preference. A floor is one of two kinds and says which in its own why. A measured floor sits immediately above a version known to break a documented procedure, and is the kind most floors here are. A target floor declares the version the repo's own toolchain is configured for, where a lower interpreter is unverified rather than known broken, and it is honest about that rather than implying a defect nobody found. Everything else is presence-only, which is deliberate: a floor nobody can justify becomes a host failure nobody can act on. An entry is required unless it declares otherwise, and an optional tool that is absent is skipped rather than failed, since it is needed by one repository rather than by the fleet. Probes are tried in order and the first that runs is the answer, which is how a host whose interpreter is not called python3 still reports a version. The pattern is matched against the probe's combined output and its first capture group is the version, read as dot-separated integers, so a two-part YYYY.MM version compares correctly against a three-part semantic one. A floor also carries a remedy beside its source: the source says where the tool must come from and why, and the remedy is the one command that installs or upgrades it on each platform, printed under a below-floor failure so the finding carries its own fix. A remedy is written for the below-floor state, which is why the host-setup installers run in their upgrade mode there. A macOS remedy runs brew install rather than brew upgrade where the entry's source is the platform rather than Homebrew: brew install upgrades a formula that is installed but outdated, per the Homebrew manpage's install entry, and brew upgrade refuses a tool Homebrew never installed, so install is the one form that fixes both below-floor states. A remedy path under host-setup/ is repo-relative and the gate resolves it against the checkout it runs from, so the printed command is runnable as printed. A platform where the tool does not apply carries no remedy key, and scripts/tests/test_bootstrap.py is what keeps this mapping total and pointing at tools the installers actually manage.", - "tools": [ - { - "name": "docker", - "required": true, - "probes": [["docker", "version", "--format", "{{.Server.Version}}"], ["docker", "--version"]], - "pattern": "^\\s*(?:Docker version )?(\\d+(?:\\.\\d+)*)", - "minimum": "29.6.2", - "why": "The four linters run as pinned images rather than local installs, so the image tag fixes the linter version and the daemon's own version has never been the thing that broke a run, and this floor is a target rather than a measured breakage for exactly that reason. It is anchored to what one host already carries, the same reasoning jq's and uv's floors use: issue #695 recorded Docker Desktop 4.85.0 and Engine 29.6.2 on a Windows host, and the floor is written against the engine number rather than the Desktop application version winget tracks separately. A host below it is therefore unverified rather than known broken. The first probe asks the daemon for its own version and the second reads the CLI's banner, in that order because the two are not one number on every host: issue #751 recorded a WSL distribution whose docker CLI came from the distribution's own package at 29.1.3 while the Docker Desktop engine it talked to over the integration socket was 29.7.2, so reading the banner failed a host that clears this floor comfortably. The banner is the fallback rather than the reading, since a daemon that is stopped or unreachable makes the first probe exit non-zero and a client version is a weaker answer rather than a wrong one, which is what a native install, where the CLI and the engine come from one package, reports anyway. One expression reads both probes, which is what makes the `Docker version ` prefix optional and the pattern anchored: unanchored, an optional prefix reads the first digits anywhere in the output rather than the version the probe was asked for. Inside a WSL distribution, docker comes from Docker Desktop's own WSL integration rather than a local package, and the Linux remedy below reflects that: run inside a WSL distribution, it refuses the native install and points at enabling that integration and at upgrading Docker Desktop on the Windows host the distribution runs under, rather than installing anything itself.", - "source": { - "linux": "Docker's own apt repository at download.docker.com, per https://docs.docker.com/engine/install/, and never the distribution's docker.io package, which trails upstream and conflicts with docker-ce. Not applicable inside a WSL distribution, which takes docker only from Docker Desktop's own WSL integration.", - "macos": "Docker Desktop for Mac, via Homebrew Cask, which tracks upstream Desktop releases.", - "windows": "winget (Docker.DockerDesktop), which tracks upstream Desktop releases." - }, - "remedy": { - "linux": "host-setup/linux/install-tools.sh --upgrade docker", - "macos": "brew install --cask docker", - "windows": "host-setup/windows/install-tools.ps1 -Upgrade docker" - } - }, - { - "name": "gh", - "required": true, - "probes": [["gh", "--version"]], - "pattern": "gh version (\\d+(?:\\.\\d+)*)", - "minimum": "2.47.0", - "why": "The GitHub CLI maintainers name the community-distributed 2.45.x and 2.46.x as broken by deprecated GitHub APIs, so the floor sits immediately above that range rather than at a version anyone has verified good. Two symptoms were observed on a distribution 2.46.0 and re-tested as resolved on 2.97.0, and OPERATIONS.md records both: gh pr edit failed on the Projects-classic projectCards field whichever argument it was given and exited non-zero having applied nothing, and gh pr checks carried no --json flag, so a watcher built on it printed nothing and a quiet result read as a passing one. Neither is visible to a presence check, since a tool old enough to be broken answers --version cleanly.", - "source": { - "linux": "The official apt repository at cli.github.com, per https://github.com/cli/cli/blob/trunk/docs/install_linux.md, and never the distribution's own package.", - "macos": "Homebrew, which tracks upstream releases.", - "windows": "winget, which tracks upstream releases, so the hazard the Linux note describes does not arise." - }, - "remedy": { - "linux": "host-setup/linux/install-tools.sh --upgrade gh", - "macos": "brew upgrade gh", - "windows": "host-setup/windows/install-tools.ps1 -Upgrade gh" - } - }, - { - "name": "git", - "required": true, - "probes": [["git", "--version"]], - "pattern": "git version (\\d+(?:\\.\\d+)*)", - "minimum": null, - "why": "No floor, and the interaction worth knowing runs the other way: git 2.51 announced the deprecation of git whatchanged and current git gates it behind a hidden flag, which is what silently disables a git-restore-mtime older than 2025.08. A newer git is therefore the trigger rather than the remedy, and the floor belongs on that tool instead." - }, - { - "name": "git-restore-mtime", - "required": false, - "probes": [["git", "restore-mtime", "--version"], ["git-restore-mtime", "--version"]], - "pattern": "version (\\d+(?:\\.\\d+)*)", - "minimum": "2025.08", - "why": "A release before 2025.08 shells out to git whatchanged, which current git refuses without a hidden opt-in flag the caller cannot pass through, so the tool restores nothing, prints its ordinary statistics and exits 0. A deploy keyed on mtimes then ships a full copy and reports success. Debian and Ubuntu both package 2022.12, so the distribution package is the failing case rather than an unlucky one.", - "source": { - "linux": "The upstream release at https://github.com/MestreLion/git-tools, or in CI the action at https://github.com/chetan/git-restore-mtime-action which vendors it, and never the distribution's own package.", - "macos": "Homebrew's git-tools, checked against the floor like any other source.", - "windows": "Not applicable, since the tool is used by a Linux deploy path." - }, - "remedy": { - "linux": "host-setup/linux/install-tools.sh --upgrade git-restore-mtime", - "macos": "brew upgrade git-tools" - } - }, - { - "name": "jq", - "required": true, - "probes": [["jq", "--version"]], - "pattern": "jq-(\\d+(?:\\.\\d+)*)", - "minimum": "1.7", - "why": "Needed by the ruleset normalizer in repo-config/configure.sh, the ruleset diff in AUDIT.md section 6, and the payload regeneration in docs/repo-config-carry.md, so a host without it cannot apply, check, or audit a repository's branch configuration. The floor is a target rather than a measured breakage one version below it, and it says so rather than implying a defect nobody found. It is anchored to what the fleet's own platform provides: current Debian stable packages 1.7.1, so a distribution install satisfies the floor and no host needs a manual build to meet it, while an older release sits below and is the case the floor is for. That anchor is what keeps it actionable, since a floor set to the newest release instead would fail a host running every documented filter correctly. Two lower versions matter to a reader and neither is the floor. 1.6 is where walk/1 arrived, so the floor sits above it and walk is available, and the filters nonetheless define their own recursion, which costs nothing and keeps them compiling below the floor. On jq 1.5 walk/1 does not degrade, it fails to compile the filter, so a diff built on it reports drift on every ruleset it never compared, which is the inverse of the false clean the comparison exists to close. The neighbouring claim that keys_unsorted needs 1.6 is disproved rather than assumed, measured on jq-1.5-1-a5b5cbe where keys_unsorted evaluates and walk(.) answers a compile error, so the two builtins are not in the same position and the analogy between them is the whole of what carried that claim. A host below the floor is therefore unverified rather than known broken, since every filter here is written to a feature set older than the floor requires. A separate implementation is worth knowing about and is not this entry's subject: gh --jq is gojq built into the CLI rather than this binary, and it emits object keys sorted, which is why the section 6 filter canonicalizes key order itself rather than depending on whichever implementation produced its input.", - "source": { - "linux": "The distribution package where it meets the floor, which on current Debian stable it does at 1.7.1, so the floor is chosen to be satisfiable without a manual build. An older release sits below it, and there the upstream release at https://github.com/jqlang/jq is the source. Preferring the current upstream release everywhere keeps the fleet on one version in practice, which is what the other two platforms already do.", - "macos": "Homebrew, which tracks upstream releases.", - "windows": "winget, which tracks upstream releases, so the hazard the Linux note describes does not arise." - }, - "remedy": { - "linux": "host-setup/linux/install-tools.sh --upgrade jq", - "macos": "brew upgrade jq", - "windows": "host-setup/windows/install-tools.ps1 -Upgrade jq" - } - }, - { - "name": "python3", - "required": true, - "probes": [["python3", "--version"], ["py", "-3", "--version"]], - "pattern": "Python (\\d+(?:\\.\\d+)*)", - "minimum": "3.13", - "why": "Every script here is standard library only, so a bare interpreter is enough and no package floor exists. The floor is the toolchain target rather than a measured breakage one version below it, which is the one entry here that reads that way and says so rather than implying a defect nobody found. pyproject.toml sets ruff target-version to py313 and mypy python_version to 3.13, so what those tools report describes 3.13 and describes no other interpreter, and a run below the floor is unverified rather than known broken. Both tools run in CI through uvx at their latest releases, beside the doc linters, the registry and spec validation, the script self-tests under coverage, and the repo and prose gates. That run judges the code rather than any host, so this floor is still a configuration choice rather than an enforced result, and a host failing it has no CI failure to point at. Two hard requirements are measured, both sit lower, and they fail differently. str.removeprefix and str.removesuffix need 3.9, and each is called where the tree actually calls it: removeprefix in scripts/prose_lint.py and spec/audit.py, removesuffix in spec/audit.py alone. An older interpreter starts, runs, and raises AttributeError when it reaches one. datetime.UTC needs 3.11 and arrives through a module-level from datetime import UTC in spec/audit.py, scripts/pr_review.py and its tests, so an older interpreter raises ImportError before any of those modules run at all. Which mode a host sees is decided by the script it runs rather than by the interpreter alone: spec/audit.py carries both and fails at import, scripts/pr_review.py carries only the import and fails the same way, and scripts/prose_lint.py carries only the call and therefore starts, runs, and fails partway through. The name rather than the version is what differs per platform, which the second probe covers.", - "source": { - "linux": "Whatever the platform provides at or above the floor, since the scripts need an interpreter and no packages, so no distribution or build is pinned here.", - "macos": "Whatever the platform provides at or above the floor, on the same reasoning as Linux.", - "windows": "The python.org installer, which registers py, python and python3.13 but not python3, so the gate reaches it through the py -3 probe rather than the python3 name that resolves to the Microsoft Store alias stub." - }, - "remedy": { - "linux": "host-setup/linux/install-tools.sh --upgrade python", - "macos": "brew install python", - "windows": "host-setup/windows/install-tools.ps1 -Upgrade python" - } - }, - { - "name": "uv", - "required": true, - "probes": [["uv", "--version"]], - "pattern": "uv (\\d+(?:\\.\\d+)*)", - "minimum": "0.12.2", - "why": "Used for coverage runs and the Python toolchain in a Python repository, where the pinned tool versions live in that repository rather than in uv itself, so no uv feature is what a floor here would guard. The floor is a target rather than a measured breakage, and it says so rather than implying a defect nobody found. It is anchored to what two independently set-up hosts already carry rather than to a version anyone has verified good, the same reasoning jq's floor uses. A winget install on Windows carried 0.12.2 while winget already offered 0.12.4, so the winget package can lag upstream by a release, and host-setup/linux/install-tools.sh, which fetches upstream's latest release directly rather than a distribution package, carried 0.12.4 on a separate Linux host. The floor sits at the lower of the two, so a host on either managed install path already clears it, and a host below it is unverified rather than known broken.", - "source": { - "linux": "The upstream release archive at https://github.com/astral-sh/uv, which host-setup/linux/install-tools.sh fetches directly, and no distribution packages it.", - "macos": "Homebrew, which tracks upstream releases.", - "windows": "winget (astral-sh.uv), which can lag upstream by a release rather than tracking it release for release." - }, - "remedy": { - "linux": "host-setup/linux/install-tools.sh --upgrade uv", - "macos": "brew upgrade uv", - "windows": "host-setup/windows/install-tools.ps1 -Upgrade uv" - } - } - ] -} +{ + "$schema": "./host-tools.schema.json", + "note": "The host contract in docs/host-setup.md, as data a gate can read. docs/host-setup.md states which tools a host needs and which repo procedure stops working without each one; this file adds the version floors, and each floor records the defect it encodes rather than a preference. A floor is one of two kinds and says which in its own why. A measured floor sits immediately above a version known to break a documented procedure, and is the kind most floors here are. A target floor declares the version the repo's own toolchain is configured for, where a lower interpreter is unverified rather than known broken, and it is honest about that rather than implying a defect nobody found. Everything else is presence-only, which is deliberate: a floor nobody can justify becomes a host failure nobody can act on. An entry is required unless it declares otherwise, and an optional tool that is absent is skipped rather than failed, since it is needed by one repository rather than by the fleet. Probes are tried in order and the first that runs is the answer, which is how a host whose interpreter is not called python3 still reports a version. The pattern is matched against the probe's combined output and its first capture group is the version, read as dot-separated integers, so a two-part YYYY.MM version compares correctly against a three-part semantic one. A floor also carries a remedy beside its source: the source says where the tool must come from and why, and the remedy is the one command that installs or upgrades it on each platform, printed under a below-floor failure so the finding carries its own fix. A remedy is written for the below-floor state, which is why the host-setup installers run in their upgrade mode there. A macOS remedy runs brew install rather than brew upgrade where the entry's source is the platform rather than Homebrew: brew install upgrades a formula that is installed but outdated, per the Homebrew manpage's install entry, and brew upgrade refuses a tool Homebrew never installed, so install is the one form that fixes both below-floor states. A remedy path under host-setup/ is repo-relative and the gate resolves it against the checkout it runs from, so the printed command is runnable as printed. A platform where the tool does not apply carries no remedy key, and scripts/tests/test_bootstrap.py is what keeps this mapping total and pointing at tools the installers actually manage.", + "tools": [ + { + "name": "docker", + "required": true, + "probes": [["docker", "version", "--format", "{{.Server.Version}}"], ["docker", "--version"]], + "pattern": "^\\s*(?:Docker version )?(\\d+(?:\\.\\d+)*)", + "minimum": "29.6.2", + "why": "The four linters run as pinned images rather than local installs, so the image tag fixes the linter version and the daemon's own version has never been the thing that broke a run, and this floor is a target rather than a measured breakage for exactly that reason. It is anchored to what one host already carries, the same reasoning jq's and uv's floors use: issue #695 recorded Docker Desktop 4.85.0 and Engine 29.6.2 on a Windows host, and the floor is written against the engine number rather than the Desktop application version winget tracks separately. A host below it is therefore unverified rather than known broken. The first probe asks the daemon for its own version and the second reads the CLI's banner, in that order because the two are not one number on every host: issue #751 recorded a WSL distribution whose docker CLI came from the distribution's own package at 29.1.3 while the Docker Desktop engine it talked to over the integration socket was 29.7.2, so reading the banner failed a host that clears this floor comfortably. The banner is the fallback rather than the reading, since a daemon that is stopped or unreachable makes the first probe exit non-zero and a client version is a weaker answer rather than a wrong one, which is what a native install, where the CLI and the engine come from one package, reports anyway. One expression reads both probes, which is what makes the `Docker version ` prefix optional and the pattern anchored: unanchored, an optional prefix reads the first digits anywhere in the output rather than the version the probe was asked for. Inside a WSL distribution, docker comes from Docker Desktop's own WSL integration rather than a local package, and the Linux remedy below reflects that: run inside a WSL distribution, it refuses the native install and points at enabling that integration and at upgrading Docker Desktop on the Windows host the distribution runs under, rather than installing anything itself.", + "source": { + "linux": "Docker's own apt repository at download.docker.com, per https://docs.docker.com/engine/install/, and never the distribution's docker.io package, which trails upstream and conflicts with docker-ce. Not applicable inside a WSL distribution, which takes docker only from Docker Desktop's own WSL integration.", + "macos": "Docker Desktop for Mac, via Homebrew Cask, which tracks upstream Desktop releases.", + "windows": "winget (Docker.DockerDesktop), which tracks upstream Desktop releases." + }, + "remedy": { + "linux": "host-setup/linux/install-tools.sh --upgrade docker", + "macos": "brew install --cask docker", + "windows": "host-setup/windows/install-tools.ps1 -Upgrade docker" + } + }, + { + "name": "gh", + "required": true, + "probes": [["gh", "--version"]], + "pattern": "gh version (\\d+(?:\\.\\d+)*)", + "minimum": "2.47.0", + "why": "The GitHub CLI maintainers name the community-distributed 2.45.x and 2.46.x as broken by deprecated GitHub APIs, so the floor sits immediately above that range rather than at a version anyone has verified good. Two symptoms were observed on a distribution 2.46.0 and re-tested as resolved on 2.97.0, and OPERATIONS.md records both: gh pr edit failed on the Projects-classic projectCards field whichever argument it was given and exited non-zero having applied nothing, and gh pr checks carried no --json flag, so a watcher built on it printed nothing and a quiet result read as a passing one. Neither is visible to a presence check, since a tool old enough to be broken answers --version cleanly.", + "source": { + "linux": "The official apt repository at cli.github.com, per https://github.com/cli/cli/blob/trunk/docs/install_linux.md, and never the distribution's own package.", + "macos": "Homebrew, which tracks upstream releases.", + "windows": "winget, which tracks upstream releases, so the hazard the Linux note describes does not arise." + }, + "remedy": { + "linux": "host-setup/linux/install-tools.sh --upgrade gh", + "macos": "brew upgrade gh", + "windows": "host-setup/windows/install-tools.ps1 -Upgrade gh" + } + }, + { + "name": "git", + "required": true, + "probes": [["git", "--version"]], + "pattern": "git version (\\d+(?:\\.\\d+)*)", + "minimum": null, + "why": "No floor, and the interaction worth knowing runs the other way: git 2.51 announced the deprecation of git whatchanged and current git gates it behind a hidden flag, which is what silently disables a git-restore-mtime older than 2025.08. A newer git is therefore the trigger rather than the remedy, and the floor belongs on that tool instead." + }, + { + "name": "git-restore-mtime", + "required": false, + "probes": [["git", "restore-mtime", "--version"], ["git-restore-mtime", "--version"]], + "pattern": "version (\\d+(?:\\.\\d+)*)", + "minimum": "2025.08", + "why": "A release before 2025.08 shells out to git whatchanged, which current git refuses without a hidden opt-in flag the caller cannot pass through, so the tool restores nothing, prints its ordinary statistics and exits 0. A deploy keyed on mtimes then ships a full copy and reports success. Debian and Ubuntu both package 2022.12, so the distribution package is the failing case rather than an unlucky one.", + "source": { + "linux": "The upstream release at https://github.com/MestreLion/git-tools, or in CI the action at https://github.com/chetan/git-restore-mtime-action which vendors it, and never the distribution's own package.", + "macos": "Homebrew's git-tools, checked against the floor like any other source.", + "windows": "Not applicable, since the tool is used by a Linux deploy path." + }, + "remedy": { + "linux": "host-setup/linux/install-tools.sh --upgrade git-restore-mtime", + "macos": "brew upgrade git-tools" + } + }, + { + "name": "jq", + "required": true, + "probes": [["jq", "--version"]], + "pattern": "jq-(\\d+(?:\\.\\d+)*)", + "minimum": "1.7", + "why": "Needed by the ruleset normalizer in repo-config/configure.sh, the ruleset diff in AUDIT.md section 6, and the payload regeneration in docs/repo-config-carry.md, so a host without it cannot apply, check, or audit a repository's branch configuration. The floor is a target rather than a measured breakage one version below it, and it says so rather than implying a defect nobody found. It is anchored to what the fleet's own platform provides: current Debian stable packages 1.7.1, so a distribution install satisfies the floor and no host needs a manual build to meet it, while an older release sits below and is the case the floor is for. That anchor is what keeps it actionable, since a floor set to the newest release instead would fail a host running every documented filter correctly. Two lower versions matter to a reader and neither is the floor. 1.6 is where walk/1 arrived, so the floor sits above it and walk is available, and the filters nonetheless define their own recursion, which costs nothing and keeps them compiling below the floor. On jq 1.5 walk/1 does not degrade, it fails to compile the filter, so a diff built on it reports drift on every ruleset it never compared, which is the inverse of the false clean the comparison exists to close. The neighbouring claim that keys_unsorted needs 1.6 is disproved rather than assumed, measured on jq-1.5-1-a5b5cbe where keys_unsorted evaluates and walk(.) answers a compile error, so the two builtins are not in the same position and the analogy between them is the whole of what carried that claim. A host below the floor is therefore unverified rather than known broken, since every filter here is written to a feature set older than the floor requires. A separate implementation is worth knowing about and is not this entry's subject: gh --jq is gojq built into the CLI rather than this binary, and it emits object keys sorted, which is why the section 6 filter canonicalizes key order itself rather than depending on whichever implementation produced its input.", + "source": { + "linux": "The distribution package where it meets the floor, which on current Debian stable it does at 1.7.1, so the floor is chosen to be satisfiable without a manual build. An older release sits below it, and there the upstream release at https://github.com/jqlang/jq is the source. Preferring the current upstream release everywhere keeps the fleet on one version in practice, which is what the other two platforms already do.", + "macos": "Homebrew, which tracks upstream releases.", + "windows": "winget, which tracks upstream releases, so the hazard the Linux note describes does not arise." + }, + "remedy": { + "linux": "host-setup/linux/install-tools.sh --upgrade jq", + "macos": "brew upgrade jq", + "windows": "host-setup/windows/install-tools.ps1 -Upgrade jq" + } + }, + { + "name": "python3", + "required": true, + "probes": [["python3", "--version"], ["py", "-3", "--version"]], + "pattern": "Python (\\d+(?:\\.\\d+)*)", + "minimum": "3.13", + "why": "Every script here is standard library only, so a bare interpreter is enough and no package floor exists. The floor is the toolchain target rather than a measured breakage one version below it, which is the one entry here that reads that way and says so rather than implying a defect nobody found. pyproject.toml sets ruff target-version to py313 and mypy python_version to 3.13, so what those tools report describes 3.13 and describes no other interpreter, and a run below the floor is unverified rather than known broken. Both tools run in CI through uvx at their latest releases, beside the doc linters, the registry and spec validation, the script self-tests under coverage, and the repo and prose gates. That run judges the code rather than any host, so this floor is still a configuration choice rather than an enforced result, and a host failing it has no CI failure to point at. Two hard requirements are measured, both sit lower, and they fail differently. str.removeprefix and str.removesuffix need 3.9, and each is called where the tree actually calls it: removeprefix in scripts/prose_lint.py and spec/audit.py, removesuffix in spec/audit.py alone. An older interpreter starts, runs, and raises AttributeError when it reaches one. datetime.UTC needs 3.11 and arrives through a module-level from datetime import UTC in spec/audit.py, scripts/pr_review.py and its tests, so an older interpreter raises ImportError before any of those modules run at all. Which mode a host sees is decided by the script it runs rather than by the interpreter alone: spec/audit.py carries both and fails at import, scripts/pr_review.py carries only the import and fails the same way, and scripts/prose_lint.py carries only the call and therefore starts, runs, and fails partway through. The name rather than the version is what differs per platform, which the second probe covers.", + "source": { + "linux": "Whatever the platform provides at or above the floor, since the scripts need an interpreter and no packages, so no distribution or build is pinned here.", + "macos": "Whatever the platform provides at or above the floor, on the same reasoning as Linux.", + "windows": "The python.org installer, which registers py, python and python3.13 but not python3, so the gate reaches it through the py -3 probe rather than the python3 name that resolves to the Microsoft Store alias stub." + }, + "remedy": { + "linux": "host-setup/linux/install-tools.sh --upgrade python", + "macos": "brew install python", + "windows": "host-setup/windows/install-tools.ps1 -Upgrade python" + } + }, + { + "name": "uv", + "required": true, + "probes": [["uv", "--version"]], + "pattern": "uv (\\d+(?:\\.\\d+)*)", + "minimum": "0.12.2", + "why": "Used for coverage runs and the Python toolchain in a Python repository, where the pinned tool versions live in that repository rather than in uv itself, so no uv feature is what a floor here would guard. The floor is a target rather than a measured breakage, and it says so rather than implying a defect nobody found. It is anchored to what two independently set-up hosts already carry rather than to a version anyone has verified good, the same reasoning jq's floor uses. A winget install on Windows carried 0.12.2 while winget already offered 0.12.4, so the winget package can lag upstream by a release, and host-setup/linux/install-tools.sh, which fetches upstream's latest release directly rather than a distribution package, carried 0.12.4 on a separate Linux host. The floor sits at the lower of the two, so a host on either managed install path already clears it, and a host below it is unverified rather than known broken.", + "source": { + "linux": "The upstream release archive at https://github.com/astral-sh/uv, which host-setup/linux/install-tools.sh fetches directly, and no distribution packages it.", + "macos": "Homebrew, which tracks upstream releases.", + "windows": "winget (astral-sh.uv), which can lag upstream by a release rather than tracking it release for release." + }, + "remedy": { + "linux": "host-setup/linux/install-tools.sh --upgrade uv", + "macos": "brew upgrade uv", + "windows": "host-setup/windows/install-tools.ps1 -Upgrade uv" + } + } + ] +} diff --git a/spec/host-tools.schema.json b/spec/host-tools.schema.json index 0a6aba39..ef396be4 100644 --- a/spec/host-tools.schema.json +++ b/spec/host-tools.schema.json @@ -1,99 +1,99 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/ptr727/ProjectTemplate/spec/host-tools.schema.json", - "title": "Host tool contract", - "description": "The tools a host must provide, with the version floor each known defect requires. Read by scripts/host_gate.py and shape-checked by spec/validate.py.", - "type": "object", - "required": ["tools"], - "additionalProperties": false, - "properties": { - "$schema": { "type": "string" }, - "note": { "type": "string" }, - "tools": { - "type": "array", - "minItems": 1, - "items": { "$ref": "#/$defs/tool" } - } - }, - "$defs": { - "tool": { - "type": "object", - "required": ["name", "probes", "pattern", "minimum", "why"], - "additionalProperties": false, - "allOf": [ - { - "title": "A declared floor carries a source and a remedy", - "description": "A host below a floor is told where to install from and the command that does it, rather than only to upgrade, which is the whole reason source and remedy exist. spec/validate.py enforces this, and encoding it here stops a declaration being schema-valid and validator-invalid at once. A null minimum declares no floor, so the requirement does not apply to it.", - "if": { - "required": ["minimum"], - "properties": { "minimum": { "type": "string" } } - }, - "then": { - "required": ["source", "remedy"], - "properties": { - "source": { "minProperties": 1 }, - "remedy": { "minProperties": 1 } - } - } - } - ], - "properties": { - "name": { - "type": "string", - "minLength": 1, - "description": "The tool as a reader names it, and the key the gate reports under." - }, - "required": { - "type": "boolean", - "default": true, - "description": "False where the tool serves one repository rather than the fleet, in which case its absence is skipped rather than failed." - }, - "probes": { - "type": "array", - "minItems": 1, - "description": "Argument lists tried in order, so the first that runs answers. A second probe exists for a tool whose name differs per platform.", - "items": { - "type": "array", - "minItems": 1, - "items": { "type": "string", "minLength": 1 } - } - }, - "pattern": { - "type": "string", - "minLength": 1, - "description": "Matched against the probe's combined output, with the first capture group taken as the version. It does not anchor on the tool's own name where the tool prints its invoked name instead." - }, - "minimum": { - "type": ["string", "null"], - "pattern": "^\\d+(\\.\\d+)*$", - "description": "The lowest acceptable version as dot-separated integers, or null where no floor applies. A floor is either measured, sitting above a version known to break a documented procedure, or a target, naming the version the repo's toolchain is configured for. The why field says which, since the two carry different weight to a host that fails one." - }, - "why": { - "type": "string", - "minLength": 1, - "description": "The defect the floor encodes, or the reason no floor exists. This is the field that keeps a floor from becoming folklore." - }, - "source": { - "type": "object", - "description": "Where the tool must come from, per platform, where the source rather than the presence is what the contract requires.", - "additionalProperties": false, - "properties": { - "linux": { "type": "string", "minLength": 1 }, - "macos": { "type": "string", "minLength": 1 }, - "windows": { "type": "string", "minLength": 1 } - } - }, - "remedy": { - "type": "object", - "description": "The command that installs or upgrades the tool, per platform, printed under a below-floor failure so the finding carries its own fix. A host-setup/ path is repo-relative and the gate resolves it against the checkout it runs from. A platform where the tool does not apply carries no key.", - "additionalProperties": false, - "properties": { - "linux": { "type": "string", "minLength": 1 }, - "macos": { "type": "string", "minLength": 1 }, - "windows": { "type": "string", "minLength": 1 } - } - } - } - } - } -} +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/ptr727/ProjectTemplate/spec/host-tools.schema.json", + "title": "Host tool contract", + "description": "The tools a host must provide, with the version floor each known defect requires. Read by scripts/host_gate.py and shape-checked by spec/validate.py.", + "type": "object", + "required": ["tools"], + "additionalProperties": false, + "properties": { + "$schema": { "type": "string" }, + "note": { "type": "string" }, + "tools": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/tool" } + } + }, + "$defs": { + "tool": { + "type": "object", + "required": ["name", "probes", "pattern", "minimum", "why"], + "additionalProperties": false, + "allOf": [ + { + "title": "A declared floor carries a source and a remedy", + "description": "A host below a floor is told where to install from and the command that does it, rather than only to upgrade, which is the whole reason source and remedy exist. spec/validate.py enforces this, and encoding it here stops a declaration being schema-valid and validator-invalid at once. A null minimum declares no floor, so the requirement does not apply to it.", + "if": { + "required": ["minimum"], + "properties": { "minimum": { "type": "string" } } + }, + "then": { + "required": ["source", "remedy"], + "properties": { + "source": { "minProperties": 1 }, + "remedy": { "minProperties": 1 } + } + } + } + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "The tool as a reader names it, and the key the gate reports under." + }, + "required": { + "type": "boolean", + "default": true, + "description": "False where the tool serves one repository rather than the fleet, in which case its absence is skipped rather than failed." + }, + "probes": { + "type": "array", + "minItems": 1, + "description": "Argument lists tried in order, so the first that runs answers. A second probe exists for a tool whose name differs per platform.", + "items": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + } + }, + "pattern": { + "type": "string", + "minLength": 1, + "description": "Matched against the probe's combined output, with the first capture group taken as the version. It does not anchor on the tool's own name where the tool prints its invoked name instead." + }, + "minimum": { + "type": ["string", "null"], + "pattern": "^\\d+(\\.\\d+)*$", + "description": "The lowest acceptable version as dot-separated integers, or null where no floor applies. A floor is either measured, sitting above a version known to break a documented procedure, or a target, naming the version the repo's toolchain is configured for. The why field says which, since the two carry different weight to a host that fails one." + }, + "why": { + "type": "string", + "minLength": 1, + "description": "The defect the floor encodes, or the reason no floor exists. This is the field that keeps a floor from becoming folklore." + }, + "source": { + "type": "object", + "description": "Where the tool must come from, per platform, where the source rather than the presence is what the contract requires.", + "additionalProperties": false, + "properties": { + "linux": { "type": "string", "minLength": 1 }, + "macos": { "type": "string", "minLength": 1 }, + "windows": { "type": "string", "minLength": 1 } + } + }, + "remedy": { + "type": "object", + "description": "The command that installs or upgrades the tool, per platform, printed under a below-floor failure so the finding carries its own fix. A host-setup/ path is repo-relative and the gate resolves it against the checkout it runs from. A platform where the tool does not apply carries no key.", + "additionalProperties": false, + "properties": { + "linux": { "type": "string", "minLength": 1 }, + "macos": { "type": "string", "minLength": 1 }, + "windows": { "type": "string", "minLength": 1 } + } + } + } + } + } +} diff --git a/spec/project-types.json b/spec/project-types.json index 5d07fb65..2c783d64 100644 --- a/spec/project-types.json +++ b/spec/project-types.json @@ -1,195 +1,195 @@ -{ - "$schema": "./project-types.schema.json", - "note": "Type -> requirements and checks. Each check names a verdict tier (letter = exact form, intent = equivalent outcome) and points at the prose section that owns the rationale. The audit (AUDIT.md) runs the checks for a repo's declared types plus the cross-cutting dimensions. An absent construct is N/A, not a defect. How types and profiles are declared, validated against detection, and gated by profile is defined in spec/type-model.md.", - "types": { - "csharp": { - "detect": ["*.csproj", "*.slnx", "*.sln"], - "profiles": ["build"], - "intentRefs": ["CODESTYLE.md", "GOVERNANCE.md#line-endings"], - "requiredFiles": [".editorconfig", ".gitattributes"], - "checks": [ - { "id": "csharp.editorconfig.ruleblock", "verdict": "letter", "assert": ".editorconfig carries the shared [*.cs] plus ReSharper rule block.", "intentRef": "CODESTYLE.md" }, - { "id": "csharp.analyzers.zerowarnings", "verdict": "intent", "assert": "Analyzer severities are enforced; warnings are not relaxed or suppressed wholesale.", "intentRef": "CODESTYLE.md" }, - { "id": "csharp.centralconfig.props", "verdict": "letter", "assert": "Shared MSBuild configuration is centralized at the repo root: Directory.Build.props carries the common analyzer and warning properties (the Zero Warnings set), and Directory.Packages.props enables ManagePackageVersionsCentrally with every dependency version declared once - a csproj carries only project-specific properties and versionless PackageReference items.", "intentRef": "CODESTYLE.md" }, - { "id": "csharp.coverage.codecov", "verdict": "letter", "assert": "The unit-test job collects coverage (dotnet test --collect:\"XPlat Code Coverage\" --results-directory ./coverage) and uploads it to Codecov via codecov/codecov-action, best-effort (fail_ci_if_error: false so a Codecov outage or an absent token never reds the gate). CODECOV_TOKEN is stored in the repo actions secrets and reaches the reusable validator via secrets: inherit. Required for every C# repo with tests.", "intentRef": "WORKFLOW.md", "minProfile": "build" } - ] - }, - "nuget": { - "detect": ["build-nugetlibrary-task.yml", "dotnet nuget push"], - "forbiddenSecrets": ["NUGET_API_KEY"], - "checks": [ - { "id": "nuget.publish.oidc", "verdict": "intent", "assert": "NuGet publish uses OIDC Trusted Publishing (NuGet/login), not a stored API key.", "workflowRef": "WORKFLOW.md#d4---release--publish" }, - { "id": "nuget.publish.skipduplicate", "verdict": "letter", "assert": "The push uses --skip-duplicate and is gated on push, not on an existence check.", "workflowRef": "WORKFLOW.md#d4---release--publish" } - ] - }, - "pypi": { - "detect": ["build-pypilibrary-task.yml", "pypa/gh-action-pypi-publish"], - "forbiddenSecrets": ["PYPI_API_TOKEN", "TWINE_PASSWORD"], - "checks": [ - { "id": "pypi.publish.oidc", "verdict": "intent", "assert": "PyPI publish uses OIDC Trusted Publishing (no stored token).", "workflowRef": "WORKFLOW.md#d4---release--publish" }, - { "id": "pypi.publish.environment", "verdict": "letter", "assert": "The publish job declares environment: pypi and id-token: write, with skip-existing: true.", "workflowRef": "WORKFLOW.md#d7---concurrency-permissions-safety" } - ] - }, - "python": { - "detect": ["pyproject.toml", "setup.py"], - "profiles": ["build", "lint-only"], - "canonicalPlacement": "pyproject.toml", - "profileNote": "The declared profile is build or lint-only, each with a structural pyproject.toml shape (CODESTYLE.md Python 'Two profiles'). The build profile (structurally the PROJECT shape) is Python with third-party runtime dependencies or as the repo's deliverable - a PEP 621 uv project (pyproject [project]+deps+[build-system], committed uv.lock, uv sync --frozen + uv run in CI). The lint-only profile (structurally the SCRIPTS shape) is stdlib-only utility scripts embedded in a non-Python repo (e.g. a Python tooling subtree of a csharp app) - run with uvx, no uv.lock, no uv project, pyproject carries only [tool.ruff]/[tool.mypy] config. The two differ by whether the Python has third-party runtime dependencies, which the audit detects structurally from pyproject.toml (see python.profile.detect) rather than by inspecting imports. The shape [project]+deps/[build-system] + uv.lock is build, and tool-config-only with no [project]/[build-system] and no uv.lock is lint-only. The declared profile corresponds to this shape, and each check names the minimum profile it needs (see python.profile.detect and each check's minProfile).", - "checks": [ - { "id": "python.profile.detect", "verdict": "letter", "assert": "The declared profile corresponds to the pyproject.toml shape. A [project] table with runtime dependencies (or a [build-system]) is the build profile (the PROJECT shape). A pyproject carrying only [tool.*] config with no [project]/[build-system] and no uv.lock is the lint-only profile (the SCRIPTS shape). A lint-only subtree must not carry a uv.lock or project/build metadata, which would misrepresent it as a shippable package, and a build one must.", "intentRef": "CODESTYLE.md" }, - { "id": "python.ruff.config", "verdict": "intent", "assert": "A ruff configuration is present (pyproject.toml [tool.ruff]). Both profiles.", "intentRef": "CODESTYLE.md" }, - { "id": "python.pyright.config", "verdict": "intent", "assert": "Build profile: pyright is configured and runs strict on first-party code (src or the integration package) - the strong typing baseline. Third-party strictness is relaxed only where a dependency has no usable types. N/A for the lint-only profile, whose type checker is mypy over stdlib-only code (python.mypy.allowed).", "intentRef": "CODESTYLE.md", "minProfile": "build" }, - { "id": "python.config.placement", "verdict": "letter", "assert": "ruff and the type-checker config live in pyproject.toml (canonical); standalone .ruff.toml / pyrightconfig.json is a drift finding. A Home Assistant integration is the exception - it follows home-assistant/core standalone-config conventions and is scored by ha.python.conventions instead.", "intentRef": "CODESTYLE.md" }, - { "id": "python.mypy.allowed", "verdict": "intent", "assert": "mypy is permitted as an additional type checker, not banned. It is required for a Home Assistant integration (platinum strict-typing) and is the lint-only profile's type checker. When used it runs in CI and the editor.", "intentRef": "CODESTYLE.md" }, - { "id": "python.coverage.codecov", "verdict": "letter", "assert": "The test job collects coverage (pytest --cov-report=xml) and uploads it to Codecov via codecov/codecov-action, best-effort (continue-on-error and fail_ci_if_error: false). CODECOV_TOKEN is stored in the repo actions secrets. Required for every Python repo with tests. N/A for the lint-only profile (its unittest suite runs under coverage in CI, reported without a threshold and never uploaded to Codecov). In a mixed repo the codecov.yml file-presence is still required by any co-present type that has tests, e.g. csharp.", "intentRef": "WORKFLOW.md", "minProfile": "build" }, - { "id": "python.uvlock.pinned", "verdict": "letter", "assert": "Build profile: the committed uv.lock is pinned to LF in both .editorconfig ([uv.lock]) and .gitattributes (uv.lock text eol=lf). uv regenerates it LF on every platform, so a CRLF-default repo otherwise reds editorconfig-checker on every uv lock/sync. N/A for a non-uv Python repo (a Home Assistant integration on pip/requirements) and for the lint-only profile (no uv.lock by definition).", "intentRef": "GOVERNANCE.md#line-endings", "minProfile": "build" }, - { "id": "python.scripts.uvx", "verdict": "letter", "assert": "Lint-only profile only: the tools run via uvx (no project install, no lockfile). A uvx <tool>@<ver> pin in a run: step is not Dependabot-trackable, so CI runs uvx ruff@latest / uvx mypy@latest - the fleet rule pins only what Dependabot auto-updates and otherwise runs latest, never a manual pin that goes stale. VS Code tasks, README, and CI all run the unpinned latest. N/A for the build profile (which pins tool versions via uv.lock + uv sync --frozen instead).", "intentRef": "CODESTYLE.md" } - ] - }, - "cpp": { - "detect": ["*.cpp", "*.cxx", "*.cc", "*.hpp", "*.hxx", "*.h", "*.ino", "*.c"], - "profiles": ["lint-only"], - "intentRefs": ["CODESTYLE.md"], - "checks": [ - { "id": "cpp.clangformat.shared", "verdict": "intent", "assert": "A clang-format configuration (.clang-format) drives C/C++ formatting and is shared by the editor, the CLI, and CI, feeding the operational lint gate. The scope is style only. Semantic and static analysis are left to the downstream toolchain that compiles the code (an ESPHome build), which has the compile context clang-tidy would need. A repo's .h is treated as C++ by context.", "intentRef": "CODESTYLE.md" } - ] - }, - "console": { - "detect": ["build-executable-task.yml", "System.CommandLine"], - "checks": [ - { "id": "console.smoke.subset", "verdict": "letter", "assert": "The smoke runtime matrix is a strict non-empty subset of the full matrix.", "workflowRef": "WORKFLOW.md#6-per-project-type-test-walkthroughs" }, - { "id": "console.aggregate.asset", "verdict": "letter", "assert": "Per-runtime outputs are aggregated by pattern+merge-multiple into one release-asset-<branch>-<target>, gated !smoke.", "workflowRef": "WORKFLOW.md#d6---seam--architecture-conformance" } - ] - }, - "docker": { - "detect": ["Dockerfile", "build-docker-task.yml"], - "requiredSecrets": ["DOCKER_HUB_USERNAME", "DOCKER_HUB_ACCESS_TOKEN"], - "checks": [ - { "id": "docker.cache.registry", "verdict": "intent", "assert": "Layer cache targets a registry tag (buildcache-<branch>), never type=gha.", "workflowRef": "WORKFLOW.md#d9---style--static-see-section-2" }, - { "id": "docker.hub.readme", "verdict": "intent", "assert": "Where the Docker Hub overview differs from the project README, a Docker/README.md is published via the docker-readme task (Hub description is size-limited).", "workflowRef": "WORKFLOW.md#6-per-project-type-test-walkthroughs" }, - { "id": "docker.always.repush", "verdict": "letter", "assert": "The image always re-pushes on publish (base-image refresh), independent of the release-create skip.", "workflowRef": "WORKFLOW.md#d4---release--publish" } - ] - }, - "homeassistant": { - "detect": ["custom_components/*/manifest.json", "hacs.json"], - "checks": [ - { "id": "ha.release.only", "verdict": "intent", "assert": "Distribution is a GitHub release for HACS (push consumer model); no registry publish.", "workflowRef": "WORKFLOW.md#d4---release--publish" }, - { "id": "ha.python.conventions", "verdict": "intent", "assert": "Follows home-assistant/core Python conventions (pip + requirements*.txt, custom_components/<domain> layout, standalone .ruff.toml + pyrightconfig.json) - expected, not drift.", "intentRef": "CODESTYLE.md" }, - { "id": "ha.typecheck.mypy", "verdict": "letter", "assert": "mypy --strict runs in CI (platinum strict-typing quality-scale tier).", "intentRef": "CODESTYLE.md" } - ] - }, - "eda": { - "detect": ["*.kicad_pcb", "*.kicad_sym", "*.kicad_mod", "*.kicad_sch", "*.pretty"], - "checks": [ - { "id": "eda.release.zip", "verdict": "intent", "assert": "Distribution is a GitHub release data zip pulled into a local EDA install (pull consumer); no package or image registry.", "workflowRef": "WORKFLOW.md#d4---release--publish" }, - { "id": "eda.validation", "verdict": "intent", "assert": "CI runs EDA-specific validation (e.g. kicad-cli ERC/DRC or library linting) - the design-data analogue of the code linters.", "intentRef": "CODESTYLE.md" }, - { "id": "eda.build.deterministic", "verdict": "intent", "assert": "Any build-time artifact generation (gerbers, drill, BOM) and version injection is deterministic from the design inputs; aspirational for data-only repos not yet building artifacts.", "workflowRef": "WORKFLOW.md#d3---versioning-and-classification" } - ] - }, - "codegen": { - "detect": ["run-codegen-pull-request-task.yml"], - "requiredSecrets": [], - "checks": [ - { "id": "codegen.deterministic", "verdict": "intent", "assert": "Codegen output is deterministic from its inputs (no per-run timestamps/GUIDs); runs as a matrix over both branches.", "workflowRef": "WORKFLOW.md#d8---bots--automation" } - ] - }, - "upstream-wrapper": { - "detect": ["check-upstream-version-task.yml", "upstream-version.json"], - "checks": [ - { "id": "wrapper.tag.fromstate", "verdict": "letter", "assert": "The build reads the committed name->version state file for the tag instead of SemVer2.", "workflowRef": "WORKFLOW.md#d3---versioning-and-classification" }, - { "id": "wrapper.tracker.branches", "verdict": "letter", "assert": "The tracker's bump-branch-prefix and branches match the merge-bot's head/base pairs.", "workflowRef": "WORKFLOW.md#d8---bots--automation" } - ] - }, - "source-only": { - "detect": ["no build-*-task.yml"], - "checks": [ - { "id": "sourceonly.release.tagonly", "verdict": "letter", "assert": "The standalone publish-release.yml inlines action-gh-release (no build-release-task.yml, no expect_release_assets) and produces a release of tag + source zip + README + LICENSE.", "workflowRef": "WORKFLOW.md#6-per-project-type-test-walkthroughs" }, - { "id": "sourceonly.nbgv.retained", "verdict": "letter", "assert": "version.json is retained and NBGV is inlined in publish-release.yml to compute the tag.", "workflowRef": "WORKFLOW.md#d3---versioning-and-classification" } - ] - }, - "hugo": { - "detect": ["hugo.yaml", "hugo.toml", "config/_default/hugo.yaml"], - "intentRefs": ["WORKFLOW.md"], - "note": "Named for the generator rather than for the transport, because what a repo builds and where the result lands are separate axes. The destination is publish[] ({ target, mechanism }), so a repo changes transport without changing type. Every assert below is phrased without naming the generator except hugo.build.strict, where a generator-specific flag is the letter, so promoting the generic ones to a shared type when a second generator arrives is a registry edit. Deploy credentials are per-environment GitHub Environment secrets and variables, which spec/secrets.json cannot yet express, so a repo does not list them in its registry requiredSecrets: spec/audit.py resolves that list against the repository actions store and would report an environment-scoped name as missing.", - "checks": [ - { "id": "hugo.build.strict", "verdict": "letter", "assert": "The site build fails on a generator warning rather than rendering around it (hugo --gc --minify --panicOnWarning), and the pull request gate and the deploy run the same build command rather than two variants.", "workflowRef": "WORKFLOW.md#d1---pr-fast-feedback-smoke" }, - { "id": "hugo.urls.parity", "verdict": "letter", "assert": "A URL contract gate compares the built tree against a committed list of the URLs that must render and the URLs that must redirect, and asserts a minimum length on each list before comparing it, since a truncated list makes every assertion below it pass vacuously. This is the type's check of record, standing in for the unit tests a site does not have.", "workflowRef": "WORKFLOW.md#6-per-project-type-test-walkthroughs" }, - { "id": "hugo.output.uncommitted", "verdict": "letter", "assert": "The rendered output is produced in CI only: its roots are gitignored and untracked, and they are excluded from the prose, spelling, and Markdown gates along with any vendored third-party tree. A committed render is drift rather than a deliverable.", "intentRef": "GOVERNANCE.md#documentation-style-conventions" }, - { "id": "hugo.generator.pinned", "verdict": "letter", "assert": "The generator is pinned by exact version and by a checksum of the downloaded artifact, verified before install, never installed from a floating action or a latest tag, since the site is reproducible only if the generator is. The pin is declared once, and where two workflows need it something asserts the two copies agree.", "workflowRef": "WORKFLOW.md#d9---style--static-see-section-2" }, - { "id": "hugo.vendored.provenance", "verdict": "letter", "assert": "A vendored third-party tree records the upstream repository and the exact ref or commit it was taken from, or is carried by a mechanism that pins it, so a bot or a tracker can move it. An unpinned copy with no recorded origin cannot be updated, diffed against upstream, or audited for a security fix.", "intentRef": "GOVERNANCE.md#release-model" }, - { "id": "hugo.deploy.environment", "verdict": "letter", "assert": "The deploy job binds a GitHub Environment and takes every host-specific value and its credential from that environment, so the workflow file names no host, path, or address. A reusable callee re-asserts the environment name in a job of its own, because the environment binding resolves before any step runs and a workflow_call caller is not bound by the dispatch choice list a human sees.", "workflowRef": "WORKFLOW.md#d7---concurrency-permissions-safety" }, - { "id": "hugo.deploy.atomic", "verdict": "intent", "assert": "A release installs beside the retained ones under its own immutable id, and is published by moving a single pointer through a temporary and a rename, so no request observes a half-written site and the previous release stays on disk as a rollback target. The transport never deletes at the environment root.", "workflowRef": "WORKFLOW.md#d4---release--publish" }, - { "id": "hugo.deploy.verified", "verdict": "letter", "assert": "The deploy's terminal step observes the running host rather than the transport's exit status. It asserts which release is answering, by comparing a version the artifact stamps into the configuration it ships against the id just installed, polling to a bounded timeout because the reload is asynchronous, and it asserts which environment answered. An unreachable host is reported distinctly from an HTTP status.", "workflowRef": "WORKFLOW.md#d4---release--publish" }, - { "id": "hugo.deploy.retention", "verdict": "letter", "assert": "Retention at the destination is bounded by a declared count, and the side that owns the prune is written down rather than assumed. Where the deploy credential can observe the destination, the deploy asserts the count converged and fails when it does not. Where the credential is deliberately write-only and cannot observe it, the prune belongs to the host and that ownership is recorded, since widening the credential to reach it would trade a real boundary for a check. The release the live pointer resolves to is never a prune candidate. A prune that runs against a local scratch tree, or that is best-effort, or that no side is recorded as owning, satisfies none of this.", "workflowRef": "WORKFLOW.md#d5---resource-cleanup" } - ] - }, - "docs": { - "detect": ["governance-only repo"], - "checks": [ - { "id": "docs.ci.lintonly", "verdict": "intent", "assert": "CI runs linting only (markdownlint, cspell, JSON schema, actionlint); no build/test.", "intentRef": "AGENTS.md" } - ] - } - }, - "crossCutting": { - "workflow": { - "appliesTo": "*", - "contract": "WORKFLOW.md", - "method": "Run the WORKFLOW.md 5A static audit and 5B trace scenarios for each applicable D-guarantee against the repo's own Actions.", - "note": "GitHub Actions may be repo-specific; assert they implement the contract by outcome, not that they match the catalog snippets byte for byte." - }, - "branch-model": { - "appliesTo": "*", - "checks": [ - { "id": "branch.both-protected", "verdict": "letter", "assert": "main and develop both exist and are protected.", "intentRef": "GOVERNANCE.md#branching-model" }, - { "id": "branch.ruleset.develop", "verdict": "letter", "assert": "The live develop ruleset matches the expected payload for the repo's workflowModel (normalized diff): release -> repo-config/develop.json (PR-gated), operational -> repo-config/operational/develop.json (direct signed pushes; deletion + non_fast_forward + required_signatures only).", "intentRef": "repo-config/README.md" }, - { "id": "branch.ruleset.main", "verdict": "letter", "assert": "The live main ruleset matches repo-config/main.json (normalized diff); this ruleset is shared by both workflow models.", "intentRef": "repo-config/README.md" }, - { "id": "branch.operational.lintci", "verdict": "intent", "assert": "An operational (workflowModel) repo runs a lint/validation CI (editorconfig/EOL plus domain linters, e.g. Home Assistant or ESPHome config validation or a firmware build; no unit testing) feeding the required Check pull request workflow status job, so the develop -> main promotion PR is gated even though develop takes direct commits. N/A for release repos.", "intentRef": "GOVERNANCE.md#branching-model" }, - { "id": "branch.operational.prtriggers", "verdict": "letter", "assert": "The operational repo's lint/validation workflow triggers on pull_request with branches: [ main, develop ], plus push to develop and workflow_dispatch. A pull_request set naming main alone leaves a PR into develop matching no trigger, so it merges with no validation at all (WORKFLOW.md D1.2). The develop result is reported, not required: the required status check stays on main only, since requiring it on develop would gate the direct push the model allows. N/A for release repos.", "workflowRef": "WORKFLOW.md#6-per-project-type-test-walkthroughs" } - ] - }, - "carried-scope": { - "appliesTo": "*", - "checks": [ - { "id": "carried.hubonly.absent", "verdict": "letter", "assert": "The repo carries no file the hub hosts rather than carries. spec/audit.py derives the set as the hub's git-tracked paths minus the spec/files.json baseline, and reports each one the repo's ground-truth branch also holds. The remedy is a deletion rather than a re-vendor, since the repo reaches the hub's copy per GOVERNANCE.md 'Hub-Hosted Tooling'. The match is on path alone, so a repo's own content at a path the hub also uses matches without carrying anything of the hub's, and a spec/divergences.json 'gaps' disposition decides which case a hit is: 'retire' asserts a deletion, 'accepted' closes a path collision or a repo-owned file, and an untriaged hit is read before it is acted on.", "intentRef": "GOVERNANCE.md#hub-hosted-tooling" } - ] - }, - "repo-setup": { - "appliesTo": "*", - "checks": [ - { "id": "setup.secrets.present", "verdict": "letter", "assert": "Every requiredSecret for the repo's publish mechanisms is configured (per spec/secrets.json).", "intentRef": "repo-config/README.md" }, - { "id": "setup.secrets.noforbidden", "verdict": "letter", "assert": "No forbidden secret is present (e.g. a static NUGET_API_KEY on an OIDC repo).", "intentRef": "spec/secrets.json" }, - { "id": "setup.driftnotes.current", "verdict": "intent", "assert": "A registry driftNote records a current deviation from the baseline; once resolved the note is deleted, not left describing finished work. spec/audit.py flags a note asserting outstanding work (pending / not yet / missing / behind / ...) on a repo that otherwise audits clean. Findings derived from an audit run carry its stamp and are re-verified at pickup, never trusted as current state.", "intentRef": "AUDIT.md" }, - { "id": "setup.dependabot.ecosystems", "verdict": "intent", "assert": "For each ecosystem the repo's tree implies, .github/dependabot.yml declares it (dual-target main+develop per the fleet norm): github-actions when .github/workflows/ is present (its workflows reference actions, else their versions go stale and a stood-up merge-bot has no PRs to auto-merge), devcontainers when a .devcontainer is present. A missing implied ecosystem is a drift finding. Language ecosystems (nuget/uv/npm) are directory-scoped, audited by inspection.", "intentRef": "GOVERNANCE.md#branching-model" } - ] - }, - "linter-parity": { - "appliesTo": "*", - "checks": [ - { "id": "parity.markdownlint", "verdict": "letter", "assert": "One .markdownlint-cli2.jsonc drives the VS Code extension, the CLI, and CI, and CI runs it.", "intentRef": "GOVERNANCE.md#running-the-linters-locally-known-working-invocations" }, - { "id": "parity.cspell", "verdict": "letter", "assert": "A cspell.json (not only a workspace word list) drives the extension, the CLI, and CI.", "intentRef": "CODESTYLE.md" }, - { "id": "parity.lang", "verdict": "intent", "assert": "ruff/pyright (Python) and editorconfig/csharpier (C#) configs are shared by editor, CLI, and CI where those languages are present.", "intentRef": "CODESTYLE.md" } - ] - }, - "recurring-violations": { - "appliesTo": "*", - "priority": "high", - "checks": [ - { "id": "recurring.comments", "verdict": "letter", "assert": "Comments are concise, only the non-obvious, no prose narration, and do not grow on re-edit.", "intentRef": "GOVERNANCE.md#comments" }, - { "id": "recurring.charset", "verdict": "letter", "assert": "ASCII only in agent-authored text: no em-dash (use a spaced hyphen), no smart quotes, no stray non-ASCII.", "intentRef": "GOVERNANCE.md#character-set" }, - { "id": "recurring.spelling", "verdict": "letter", "assert": "US English spelling; the shared cspell.json sets language en-US (a bare en accepts British spellings too).", "intentRef": "CODESTYLE.md#markdown-and-spelling" }, - { "id": "recurring.eol", "verdict": "letter", "assert": "Line endings follow .editorconfig, which carries a global [*] end_of_line default plus LF pins for execution-sensitive files (shell, Dockerfiles, shebang-executable .py by path), plus workflow YAML in .github/workflows/* enforced by editorconfig-checker in CI. A per-extension-only form lacking the global [*] default is a drift finding. The global default is CRLF for release repos, or the consuming application's native platform for an operational (config) repo as recorded in the registry lineEndings field (LF for Linux-native/container config e.g. ESPHome/Home Assistant, CRLF for a Windows-native editor e.g. Vantage/Design Center) - do not re-normalize such a repo to CRLF. Edits preserve the file's endings.", "intentRef": "GOVERNANCE.md#line-endings" }, - { "id": "recurring.norepoxref", "verdict": "intent", "assert": "A carried file (AGENTS.md, GOVERNANCE.md, CODESTYLE.md, WORKFLOW.md, .github/copilot-instructions.md, repo-config/README.md, repo-config/develop.json, repo-config/main.json, spec/secrets.json, the carried AUDIT.md) carries no coordination reference: no reference to the template repo in prose or link (the coordination flow is machinery a consumer should not see - state the behavior, not the destination), and no sibling fleet repo named as an illustrative example of a rule or adoption. A contextually relevant link to a related project is NOT a coordination reference and is expected (the image that consumes this config, a library this depends on) - the test is whether the link serves a reader of this repo's content. The rule governs carried template content. A repo's own README.md and topical docs are its own content. A verbatim section is the one exception, because AGENTS.md 'Fleet Bootstrap' must name the hub to do its job and its bytes are fixed fleet-wide, so a repo cannot clear such a finding except by failing the verbatim check instead. The exception stops at the region boundary, and the same file's own prose is judged normally. spec/audit.py mechanically checks AGENTS.md, GOVERNANCE.md and .github/copilot-instructions.md for the template name outside their verbatim sections, and skips the hub itself, whose copies are the source.", "intentRef": "GOVERNANCE.md#documentation-style-conventions" } - ] - }, - "readme-structure": { - "appliesTo": "*", - "checks": [ - { "id": "readme.sections", "verdict": "letter", "assert": "The README carries the required sections and keeps the declared order, per spec/readme-sections.json, which spec/audit.py reads. Required everywhere: Build and Distribution with its Build Status, Releases and Release Notes sub-sections; Table of Contents, with no size threshold; 3rd Party Tools; License as the last section in the file. Required in a public repo: Questions or Issues. The Overview slot has no accepted aliases, so a Use Cases, Features or About heading is a rename finding. A heading the model does not name is dropped before the order comparison, so a genuinely repo-specific section sits anywhere.", "intentRef": "spec/readme-structure.md" }, - { "id": "readme.shields", "verdict": "letter", "assert": "Every shield the repo's deliverables imply is present in the section that shield names, additive over the base set, per the shieldClasses in spec/readme-sections.json. Each shield carries the section it belongs in, so the license shield is an ordinary base-class shield addressed to the closing License section rather than a rule of its own, and it is marked exclusive, meaning it must render nowhere else. Matched by shields.io endpoint rather than by alt text or reference name, resolving both reference-style and inline images, and a class is a floor, so an extra shield is not a finding.", "intentRef": "spec/readme-structure.md" }, - { "id": "readme.links", "verdict": "letter", "assert": "Reference definitions live at the foot of the file and are named by what they point at, per linkNaming and canonicalLinks in spec/readme-sections.json: a shield ends -shield, a URI ends -link, an in-page anchor and a repo-local path are bare. A destination every repo has carries the same name everywhere (github-link, actions-link, releases-link, commits-link, issues-link, discussions-link, docker-hub-link, nuget-link, pypi-link), keyed to the repo's own URLs so a third-party link keeps its descriptive name, and a repo publishing several of something prefixes the target rather than renaming (nxmeta-docker-hub-link). Grouping is reported as drift rather than letter, and covers the closed five-group set (Sections, Shields, Distribution, Repo, External), their order, sorting by reference name within a group, and a reference sitting in the group its kind implies.", "intentRef": "spec/readme-structure.md" }, - { "id": "readme.tools", "verdict": "letter", "assert": "A 3rd Party Tools entry for a tool the fleet catalog names matches that catalog, per spec/third-party-tools.json: the same link and the same description, and the description says what the tool is rather than what this repo does with it. The catalog is a standard set rather than a complete one, so a tool it does not name produces no finding, and adding a tool to it is a hub edit. The section carries no license column.", "intentRef": "spec/readme-structure.md" }, - { "id": "readme.tagline", "verdict": "letter", "assert": "The first line after the H1 is the tagline: one sentence, link-free plain text, at most 100 characters, mirrored to the GitHub About description, the HISTORY.md opening, and the Docker Hub short description. Further paragraphs below it are free prose under no length or link rule, and no mirror carries them.", "intentRef": "spec/readme-structure.md" } - ] - } - } -} +{ + "$schema": "./project-types.schema.json", + "note": "Type -> requirements and checks. Each check names a verdict tier (letter = exact form, intent = equivalent outcome) and points at the prose section that owns the rationale. The audit (AUDIT.md) runs the checks for a repo's declared types plus the cross-cutting dimensions. An absent construct is N/A, not a defect. How types and profiles are declared, validated against detection, and gated by profile is defined in spec/type-model.md.", + "types": { + "csharp": { + "detect": ["*.csproj", "*.slnx", "*.sln"], + "profiles": ["build"], + "intentRefs": ["CODESTYLE.md", "GOVERNANCE.md#line-endings"], + "requiredFiles": [".editorconfig", ".gitattributes"], + "checks": [ + { "id": "csharp.editorconfig.ruleblock", "verdict": "letter", "assert": ".editorconfig carries the shared [*.cs] plus ReSharper rule block.", "intentRef": "CODESTYLE.md" }, + { "id": "csharp.analyzers.zerowarnings", "verdict": "intent", "assert": "Analyzer severities are enforced; warnings are not relaxed or suppressed wholesale.", "intentRef": "CODESTYLE.md" }, + { "id": "csharp.centralconfig.props", "verdict": "letter", "assert": "Shared MSBuild configuration is centralized at the repo root: Directory.Build.props carries the common analyzer and warning properties (the Zero Warnings set), and Directory.Packages.props enables ManagePackageVersionsCentrally with every dependency version declared once - a csproj carries only project-specific properties and versionless PackageReference items.", "intentRef": "CODESTYLE.md" }, + { "id": "csharp.coverage.codecov", "verdict": "letter", "assert": "The unit-test job collects coverage (dotnet test --collect:\"XPlat Code Coverage\" --results-directory ./coverage) and uploads it to Codecov via codecov/codecov-action, best-effort (fail_ci_if_error: false so a Codecov outage or an absent token never reds the gate). CODECOV_TOKEN is stored in the repo actions secrets and reaches the reusable validator via secrets: inherit. Required for every C# repo with tests.", "intentRef": "WORKFLOW.md", "minProfile": "build" } + ] + }, + "nuget": { + "detect": ["build-nugetlibrary-task.yml", "dotnet nuget push"], + "forbiddenSecrets": ["NUGET_API_KEY"], + "checks": [ + { "id": "nuget.publish.oidc", "verdict": "intent", "assert": "NuGet publish uses OIDC Trusted Publishing (NuGet/login), not a stored API key.", "workflowRef": "WORKFLOW.md#d4---release--publish" }, + { "id": "nuget.publish.skipduplicate", "verdict": "letter", "assert": "The push uses --skip-duplicate and is gated on push, not on an existence check.", "workflowRef": "WORKFLOW.md#d4---release--publish" } + ] + }, + "pypi": { + "detect": ["build-pypilibrary-task.yml", "pypa/gh-action-pypi-publish"], + "forbiddenSecrets": ["PYPI_API_TOKEN", "TWINE_PASSWORD"], + "checks": [ + { "id": "pypi.publish.oidc", "verdict": "intent", "assert": "PyPI publish uses OIDC Trusted Publishing (no stored token).", "workflowRef": "WORKFLOW.md#d4---release--publish" }, + { "id": "pypi.publish.environment", "verdict": "letter", "assert": "The publish job declares environment: pypi and id-token: write, with skip-existing: true.", "workflowRef": "WORKFLOW.md#d7---concurrency-permissions-safety" } + ] + }, + "python": { + "detect": ["pyproject.toml", "setup.py"], + "profiles": ["build", "lint-only"], + "canonicalPlacement": "pyproject.toml", + "profileNote": "The declared profile is build or lint-only, each with a structural pyproject.toml shape (CODESTYLE.md Python 'Two profiles'). The build profile (structurally the PROJECT shape) is Python with third-party runtime dependencies or as the repo's deliverable - a PEP 621 uv project (pyproject [project]+deps+[build-system], committed uv.lock, uv sync --frozen + uv run in CI). The lint-only profile (structurally the SCRIPTS shape) is stdlib-only utility scripts embedded in a non-Python repo (e.g. a Python tooling subtree of a csharp app) - run with uvx, no uv.lock, no uv project, pyproject carries only [tool.ruff]/[tool.mypy] config. The two differ by whether the Python has third-party runtime dependencies, which the audit detects structurally from pyproject.toml (see python.profile.detect) rather than by inspecting imports. The shape [project]+deps/[build-system] + uv.lock is build, and tool-config-only with no [project]/[build-system] and no uv.lock is lint-only. The declared profile corresponds to this shape, and each check names the minimum profile it needs (see python.profile.detect and each check's minProfile).", + "checks": [ + { "id": "python.profile.detect", "verdict": "letter", "assert": "The declared profile corresponds to the pyproject.toml shape. A [project] table with runtime dependencies (or a [build-system]) is the build profile (the PROJECT shape). A pyproject carrying only [tool.*] config with no [project]/[build-system] and no uv.lock is the lint-only profile (the SCRIPTS shape). A lint-only subtree must not carry a uv.lock or project/build metadata, which would misrepresent it as a shippable package, and a build one must.", "intentRef": "CODESTYLE.md" }, + { "id": "python.ruff.config", "verdict": "intent", "assert": "A ruff configuration is present (pyproject.toml [tool.ruff]). Both profiles.", "intentRef": "CODESTYLE.md" }, + { "id": "python.pyright.config", "verdict": "intent", "assert": "Build profile: pyright is configured and runs strict on first-party code (src or the integration package) - the strong typing baseline. Third-party strictness is relaxed only where a dependency has no usable types. N/A for the lint-only profile, whose type checker is mypy over stdlib-only code (python.mypy.allowed).", "intentRef": "CODESTYLE.md", "minProfile": "build" }, + { "id": "python.config.placement", "verdict": "letter", "assert": "ruff and the type-checker config live in pyproject.toml (canonical); standalone .ruff.toml / pyrightconfig.json is a drift finding. A Home Assistant integration is the exception - it follows home-assistant/core standalone-config conventions and is scored by ha.python.conventions instead.", "intentRef": "CODESTYLE.md" }, + { "id": "python.mypy.allowed", "verdict": "intent", "assert": "mypy is permitted as an additional type checker, not banned. It is required for a Home Assistant integration (platinum strict-typing) and is the lint-only profile's type checker. When used it runs in CI and the editor.", "intentRef": "CODESTYLE.md" }, + { "id": "python.coverage.codecov", "verdict": "letter", "assert": "The test job collects coverage (pytest --cov-report=xml) and uploads it to Codecov via codecov/codecov-action, best-effort (continue-on-error and fail_ci_if_error: false). CODECOV_TOKEN is stored in the repo actions secrets. Required for every Python repo with tests. N/A for the lint-only profile (its unittest suite runs under coverage in CI, reported without a threshold and never uploaded to Codecov). In a mixed repo the codecov.yml file-presence is still required by any co-present type that has tests, e.g. csharp.", "intentRef": "WORKFLOW.md", "minProfile": "build" }, + { "id": "python.uvlock.pinned", "verdict": "letter", "assert": "Build profile: the committed uv.lock is pinned to LF in .gitattributes (uv.lock text eol=lf), git-level enforcement independent of the editor; the [*] .editorconfig LF default already covers it, no path-specific override needed. uv regenerates it LF on every platform, so a CRLF-default operational repo (a Windows-native registry lineEndings) otherwise reds editorconfig-checker on every uv lock/sync. N/A for a non-uv Python repo (a Home Assistant integration on pip/requirements) and for the lint-only profile (no uv.lock by definition).", "intentRef": "GOVERNANCE.md#line-endings", "minProfile": "build" }, + { "id": "python.scripts.uvx", "verdict": "letter", "assert": "Lint-only profile only: the tools run via uvx (no project install, no lockfile). A uvx <tool>@<ver> pin in a run: step is not Dependabot-trackable, so CI runs uvx ruff@latest / uvx mypy@latest - the fleet rule pins only what Dependabot auto-updates and otherwise runs latest, never a manual pin that goes stale. VS Code tasks, README, and CI all run the unpinned latest. N/A for the build profile (which pins tool versions via uv.lock + uv sync --frozen instead).", "intentRef": "CODESTYLE.md" } + ] + }, + "cpp": { + "detect": ["*.cpp", "*.cxx", "*.cc", "*.hpp", "*.hxx", "*.h", "*.ino", "*.c"], + "profiles": ["lint-only"], + "intentRefs": ["CODESTYLE.md"], + "checks": [ + { "id": "cpp.clangformat.shared", "verdict": "intent", "assert": "A clang-format configuration (.clang-format) drives C/C++ formatting and is shared by the editor, the CLI, and CI, feeding the operational lint gate. The scope is style only. Semantic and static analysis are left to the downstream toolchain that compiles the code (an ESPHome build), which has the compile context clang-tidy would need. A repo's .h is treated as C++ by context.", "intentRef": "CODESTYLE.md" } + ] + }, + "console": { + "detect": ["build-executable-task.yml", "System.CommandLine"], + "checks": [ + { "id": "console.smoke.subset", "verdict": "letter", "assert": "The smoke runtime matrix is a strict non-empty subset of the full matrix.", "workflowRef": "WORKFLOW.md#6-per-project-type-test-walkthroughs" }, + { "id": "console.aggregate.asset", "verdict": "letter", "assert": "Per-runtime outputs are aggregated by pattern+merge-multiple into one release-asset-<branch>-<target>, gated !smoke.", "workflowRef": "WORKFLOW.md#d6---seam--architecture-conformance" } + ] + }, + "docker": { + "detect": ["Dockerfile", "build-docker-task.yml"], + "requiredSecrets": ["DOCKER_HUB_USERNAME", "DOCKER_HUB_ACCESS_TOKEN"], + "checks": [ + { "id": "docker.cache.registry", "verdict": "intent", "assert": "Layer cache targets a registry tag (buildcache-<branch>), never type=gha.", "workflowRef": "WORKFLOW.md#d9---style--static-see-section-2" }, + { "id": "docker.hub.readme", "verdict": "intent", "assert": "Where the Docker Hub overview differs from the project README, a Docker/README.md is published via the docker-readme task (Hub description is size-limited).", "workflowRef": "WORKFLOW.md#6-per-project-type-test-walkthroughs" }, + { "id": "docker.always.repush", "verdict": "letter", "assert": "The image always re-pushes on publish (base-image refresh), independent of the release-create skip.", "workflowRef": "WORKFLOW.md#d4---release--publish" } + ] + }, + "homeassistant": { + "detect": ["custom_components/*/manifest.json", "hacs.json"], + "checks": [ + { "id": "ha.release.only", "verdict": "intent", "assert": "Distribution is a GitHub release for HACS (push consumer model); no registry publish.", "workflowRef": "WORKFLOW.md#d4---release--publish" }, + { "id": "ha.python.conventions", "verdict": "intent", "assert": "Follows home-assistant/core Python conventions (pip + requirements*.txt, custom_components/<domain> layout, standalone .ruff.toml + pyrightconfig.json) - expected, not drift.", "intentRef": "CODESTYLE.md" }, + { "id": "ha.typecheck.mypy", "verdict": "letter", "assert": "mypy --strict runs in CI (platinum strict-typing quality-scale tier).", "intentRef": "CODESTYLE.md" } + ] + }, + "eda": { + "detect": ["*.kicad_pcb", "*.kicad_sym", "*.kicad_mod", "*.kicad_sch", "*.pretty"], + "checks": [ + { "id": "eda.release.zip", "verdict": "intent", "assert": "Distribution is a GitHub release data zip pulled into a local EDA install (pull consumer); no package or image registry.", "workflowRef": "WORKFLOW.md#d4---release--publish" }, + { "id": "eda.validation", "verdict": "intent", "assert": "CI runs EDA-specific validation (e.g. kicad-cli ERC/DRC or library linting) - the design-data analogue of the code linters.", "intentRef": "CODESTYLE.md" }, + { "id": "eda.build.deterministic", "verdict": "intent", "assert": "Any build-time artifact generation (gerbers, drill, BOM) and version injection is deterministic from the design inputs; aspirational for data-only repos not yet building artifacts.", "workflowRef": "WORKFLOW.md#d3---versioning-and-classification" } + ] + }, + "codegen": { + "detect": ["run-codegen-pull-request-task.yml"], + "requiredSecrets": [], + "checks": [ + { "id": "codegen.deterministic", "verdict": "intent", "assert": "Codegen output is deterministic from its inputs (no per-run timestamps/GUIDs); runs as a matrix over both branches.", "workflowRef": "WORKFLOW.md#d8---bots--automation" } + ] + }, + "upstream-wrapper": { + "detect": ["check-upstream-version-task.yml", "upstream-version.json"], + "checks": [ + { "id": "wrapper.tag.fromstate", "verdict": "letter", "assert": "The build reads the committed name->version state file for the tag instead of SemVer2.", "workflowRef": "WORKFLOW.md#d3---versioning-and-classification" }, + { "id": "wrapper.tracker.branches", "verdict": "letter", "assert": "The tracker's bump-branch-prefix and branches match the merge-bot's head/base pairs.", "workflowRef": "WORKFLOW.md#d8---bots--automation" } + ] + }, + "source-only": { + "detect": ["no build-*-task.yml"], + "checks": [ + { "id": "sourceonly.release.tagonly", "verdict": "letter", "assert": "The standalone publish-release.yml inlines action-gh-release (no build-release-task.yml, no expect_release_assets) and produces a release of tag + source zip + README + LICENSE.", "workflowRef": "WORKFLOW.md#6-per-project-type-test-walkthroughs" }, + { "id": "sourceonly.nbgv.retained", "verdict": "letter", "assert": "version.json is retained and NBGV is inlined in publish-release.yml to compute the tag.", "workflowRef": "WORKFLOW.md#d3---versioning-and-classification" } + ] + }, + "hugo": { + "detect": ["hugo.yaml", "hugo.toml", "config/_default/hugo.yaml"], + "intentRefs": ["WORKFLOW.md"], + "note": "Named for the generator rather than for the transport, because what a repo builds and where the result lands are separate axes. The destination is publish[] ({ target, mechanism }), so a repo changes transport without changing type. Every assert below is phrased without naming the generator except hugo.build.strict, where a generator-specific flag is the letter, so promoting the generic ones to a shared type when a second generator arrives is a registry edit. Deploy credentials are per-environment GitHub Environment secrets and variables, which spec/secrets.json cannot yet express, so a repo does not list them in its registry requiredSecrets: spec/audit.py resolves that list against the repository actions store and would report an environment-scoped name as missing.", + "checks": [ + { "id": "hugo.build.strict", "verdict": "letter", "assert": "The site build fails on a generator warning rather than rendering around it (hugo --gc --minify --panicOnWarning), and the pull request gate and the deploy run the same build command rather than two variants.", "workflowRef": "WORKFLOW.md#d1---pr-fast-feedback-smoke" }, + { "id": "hugo.urls.parity", "verdict": "letter", "assert": "A URL contract gate compares the built tree against a committed list of the URLs that must render and the URLs that must redirect, and asserts a minimum length on each list before comparing it, since a truncated list makes every assertion below it pass vacuously. This is the type's check of record, standing in for the unit tests a site does not have.", "workflowRef": "WORKFLOW.md#6-per-project-type-test-walkthroughs" }, + { "id": "hugo.output.uncommitted", "verdict": "letter", "assert": "The rendered output is produced in CI only: its roots are gitignored and untracked, and they are excluded from the prose, spelling, and Markdown gates along with any vendored third-party tree. A committed render is drift rather than a deliverable.", "intentRef": "GOVERNANCE.md#documentation-style-conventions" }, + { "id": "hugo.generator.pinned", "verdict": "letter", "assert": "The generator is pinned by exact version and by a checksum of the downloaded artifact, verified before install, never installed from a floating action or a latest tag, since the site is reproducible only if the generator is. The pin is declared once, and where two workflows need it something asserts the two copies agree.", "workflowRef": "WORKFLOW.md#d9---style--static-see-section-2" }, + { "id": "hugo.vendored.provenance", "verdict": "letter", "assert": "A vendored third-party tree records the upstream repository and the exact ref or commit it was taken from, or is carried by a mechanism that pins it, so a bot or a tracker can move it. An unpinned copy with no recorded origin cannot be updated, diffed against upstream, or audited for a security fix.", "intentRef": "GOVERNANCE.md#release-model" }, + { "id": "hugo.deploy.environment", "verdict": "letter", "assert": "The deploy job binds a GitHub Environment and takes every host-specific value and its credential from that environment, so the workflow file names no host, path, or address. A reusable callee re-asserts the environment name in a job of its own, because the environment binding resolves before any step runs and a workflow_call caller is not bound by the dispatch choice list a human sees.", "workflowRef": "WORKFLOW.md#d7---concurrency-permissions-safety" }, + { "id": "hugo.deploy.atomic", "verdict": "intent", "assert": "A release installs beside the retained ones under its own immutable id, and is published by moving a single pointer through a temporary and a rename, so no request observes a half-written site and the previous release stays on disk as a rollback target. The transport never deletes at the environment root.", "workflowRef": "WORKFLOW.md#d4---release--publish" }, + { "id": "hugo.deploy.verified", "verdict": "letter", "assert": "The deploy's terminal step observes the running host rather than the transport's exit status. It asserts which release is answering, by comparing a version the artifact stamps into the configuration it ships against the id just installed, polling to a bounded timeout because the reload is asynchronous, and it asserts which environment answered. An unreachable host is reported distinctly from an HTTP status.", "workflowRef": "WORKFLOW.md#d4---release--publish" }, + { "id": "hugo.deploy.retention", "verdict": "letter", "assert": "Retention at the destination is bounded by a declared count, and the side that owns the prune is written down rather than assumed. Where the deploy credential can observe the destination, the deploy asserts the count converged and fails when it does not. Where the credential is deliberately write-only and cannot observe it, the prune belongs to the host and that ownership is recorded, since widening the credential to reach it would trade a real boundary for a check. The release the live pointer resolves to is never a prune candidate. A prune that runs against a local scratch tree, or that is best-effort, or that no side is recorded as owning, satisfies none of this.", "workflowRef": "WORKFLOW.md#d5---resource-cleanup" } + ] + }, + "docs": { + "detect": ["governance-only repo"], + "checks": [ + { "id": "docs.ci.lintonly", "verdict": "intent", "assert": "CI runs linting only (markdownlint, cspell, JSON schema, actionlint); no build/test.", "intentRef": "AGENTS.md" } + ] + } + }, + "crossCutting": { + "workflow": { + "appliesTo": "*", + "contract": "WORKFLOW.md", + "method": "Run the WORKFLOW.md 5A static audit and 5B trace scenarios for each applicable D-guarantee against the repo's own Actions.", + "note": "GitHub Actions may be repo-specific; assert they implement the contract by outcome, not that they match the catalog snippets byte for byte." + }, + "branch-model": { + "appliesTo": "*", + "checks": [ + { "id": "branch.both-protected", "verdict": "letter", "assert": "main and develop both exist and are protected.", "intentRef": "GOVERNANCE.md#branching-model" }, + { "id": "branch.ruleset.develop", "verdict": "letter", "assert": "The live develop ruleset matches the expected payload for the repo's workflowModel (normalized diff): release -> repo-config/develop.json (PR-gated), operational -> repo-config/operational/develop.json (direct signed pushes; deletion + non_fast_forward + required_signatures only).", "intentRef": "repo-config/README.md" }, + { "id": "branch.ruleset.main", "verdict": "letter", "assert": "The live main ruleset matches repo-config/main.json (normalized diff); this ruleset is shared by both workflow models.", "intentRef": "repo-config/README.md" }, + { "id": "branch.operational.lintci", "verdict": "intent", "assert": "An operational (workflowModel) repo runs a lint/validation CI (editorconfig/EOL plus domain linters, e.g. Home Assistant or ESPHome config validation or a firmware build; no unit testing) feeding the required Check pull request workflow status job, so the develop -> main promotion PR is gated even though develop takes direct commits. N/A for release repos.", "intentRef": "GOVERNANCE.md#branching-model" }, + { "id": "branch.operational.prtriggers", "verdict": "letter", "assert": "The operational repo's lint/validation workflow triggers on pull_request with branches: [ main, develop ], plus push to develop and workflow_dispatch. A pull_request set naming main alone leaves a PR into develop matching no trigger, so it merges with no validation at all (WORKFLOW.md D1.2). The develop result is reported, not required: the required status check stays on main only, since requiring it on develop would gate the direct push the model allows. N/A for release repos.", "workflowRef": "WORKFLOW.md#6-per-project-type-test-walkthroughs" } + ] + }, + "carried-scope": { + "appliesTo": "*", + "checks": [ + { "id": "carried.hubonly.absent", "verdict": "letter", "assert": "The repo carries no file the hub hosts rather than carries. spec/audit.py derives the set as the hub's git-tracked paths minus the spec/files.json baseline, and reports each one the repo's ground-truth branch also holds. The remedy is a deletion rather than a re-vendor, since the repo reaches the hub's copy per GOVERNANCE.md 'Hub-Hosted Tooling'. The match is on path alone, so a repo's own content at a path the hub also uses matches without carrying anything of the hub's, and a spec/divergences.json 'gaps' disposition decides which case a hit is: 'retire' asserts a deletion, 'accepted' closes a path collision or a repo-owned file, and an untriaged hit is read before it is acted on.", "intentRef": "GOVERNANCE.md#hub-hosted-tooling" } + ] + }, + "repo-setup": { + "appliesTo": "*", + "checks": [ + { "id": "setup.secrets.present", "verdict": "letter", "assert": "Every requiredSecret for the repo's publish mechanisms is configured (per spec/secrets.json).", "intentRef": "repo-config/README.md" }, + { "id": "setup.secrets.noforbidden", "verdict": "letter", "assert": "No forbidden secret is present (e.g. a static NUGET_API_KEY on an OIDC repo).", "intentRef": "spec/secrets.json" }, + { "id": "setup.driftnotes.current", "verdict": "intent", "assert": "A registry driftNote records a current deviation from the baseline; once resolved the note is deleted, not left describing finished work. spec/audit.py flags a note asserting outstanding work (pending / not yet / missing / behind / ...) on a repo that otherwise audits clean. Findings derived from an audit run carry its stamp and are re-verified at pickup, never trusted as current state.", "intentRef": "AUDIT.md" }, + { "id": "setup.dependabot.ecosystems", "verdict": "intent", "assert": "For each ecosystem the repo's tree implies, .github/dependabot.yml declares it (dual-target main+develop per the fleet norm): github-actions when .github/workflows/ is present (its workflows reference actions, else their versions go stale and a stood-up merge-bot has no PRs to auto-merge), devcontainers when a .devcontainer is present. A missing implied ecosystem is a drift finding. Language ecosystems (nuget/uv/npm) are directory-scoped, audited by inspection.", "intentRef": "GOVERNANCE.md#branching-model" } + ] + }, + "linter-parity": { + "appliesTo": "*", + "checks": [ + { "id": "parity.markdownlint", "verdict": "letter", "assert": "One .markdownlint-cli2.jsonc drives the VS Code extension, the CLI, and CI, and CI runs it.", "intentRef": "GOVERNANCE.md#running-the-linters-locally-known-working-invocations" }, + { "id": "parity.cspell", "verdict": "letter", "assert": "A cspell.json (not only a workspace word list) drives the extension, the CLI, and CI.", "intentRef": "CODESTYLE.md" }, + { "id": "parity.lang", "verdict": "intent", "assert": "ruff/pyright (Python) and editorconfig/csharpier (C#) configs are shared by editor, CLI, and CI where those languages are present.", "intentRef": "CODESTYLE.md" } + ] + }, + "recurring-violations": { + "appliesTo": "*", + "priority": "high", + "checks": [ + { "id": "recurring.comments", "verdict": "letter", "assert": "Comments are concise, only the non-obvious, no prose narration, and do not grow on re-edit.", "intentRef": "GOVERNANCE.md#comments" }, + { "id": "recurring.charset", "verdict": "letter", "assert": "ASCII only in agent-authored text: no em-dash (use a spaced hyphen), no smart quotes, no stray non-ASCII.", "intentRef": "GOVERNANCE.md#character-set" }, + { "id": "recurring.spelling", "verdict": "letter", "assert": "US English spelling; the shared cspell.json sets language en-US (a bare en accepts British spellings too).", "intentRef": "CODESTYLE.md#markdown-and-spelling" }, + { "id": "recurring.eol", "verdict": "letter", "assert": "Line endings follow .editorconfig, which carries a global [*] end_of_line default plus a CRLF pin for *.bat/*.cmd, the one type Windows requires it for. A per-extension-only form lacking the global [*] default is a drift finding. The global default is LF for release repos, or the consuming application's native platform for an operational (config) repo as recorded in the registry lineEndings field (LF for Linux-native/container config e.g. ESPHome/Home Assistant, CRLF for a Windows-native editor e.g. Vantage/Design Center) - do not re-normalize such a repo to LF. Edits preserve the file's endings.", "intentRef": "GOVERNANCE.md#line-endings" }, + { "id": "recurring.norepoxref", "verdict": "intent", "assert": "A carried file (AGENTS.md, GOVERNANCE.md, CODESTYLE.md, WORKFLOW.md, .github/copilot-instructions.md, repo-config/README.md, repo-config/develop.json, repo-config/main.json, spec/secrets.json, the carried AUDIT.md) carries no coordination reference: no reference to the template repo in prose or link (the coordination flow is machinery a consumer should not see - state the behavior, not the destination), and no sibling fleet repo named as an illustrative example of a rule or adoption. A contextually relevant link to a related project is NOT a coordination reference and is expected (the image that consumes this config, a library this depends on) - the test is whether the link serves a reader of this repo's content. The rule governs carried template content. A repo's own README.md and topical docs are its own content. A verbatim section is the one exception, because AGENTS.md 'Fleet Bootstrap' must name the hub to do its job and its bytes are fixed fleet-wide, so a repo cannot clear such a finding except by failing the verbatim check instead. The exception stops at the region boundary, and the same file's own prose is judged normally. spec/audit.py mechanically checks AGENTS.md, GOVERNANCE.md and .github/copilot-instructions.md for the template name outside their verbatim sections, and skips the hub itself, whose copies are the source.", "intentRef": "GOVERNANCE.md#documentation-style-conventions" } + ] + }, + "readme-structure": { + "appliesTo": "*", + "checks": [ + { "id": "readme.sections", "verdict": "letter", "assert": "The README carries the required sections and keeps the declared order, per spec/readme-sections.json, which spec/audit.py reads. Required everywhere: Build and Distribution with its Build Status, Releases and Release Notes sub-sections; Table of Contents, with no size threshold; 3rd Party Tools; License as the last section in the file. Required in a public repo: Questions or Issues. The Overview slot has no accepted aliases, so a Use Cases, Features or About heading is a rename finding. A heading the model does not name is dropped before the order comparison, so a genuinely repo-specific section sits anywhere.", "intentRef": "spec/readme-structure.md" }, + { "id": "readme.shields", "verdict": "letter", "assert": "Every shield the repo's deliverables imply is present in the section that shield names, additive over the base set, per the shieldClasses in spec/readme-sections.json. Each shield carries the section it belongs in, so the license shield is an ordinary base-class shield addressed to the closing License section rather than a rule of its own, and it is marked exclusive, meaning it must render nowhere else. Matched by shields.io endpoint rather than by alt text or reference name, resolving both reference-style and inline images, and a class is a floor, so an extra shield is not a finding.", "intentRef": "spec/readme-structure.md" }, + { "id": "readme.links", "verdict": "letter", "assert": "Reference definitions live at the foot of the file and are named by what they point at, per linkNaming and canonicalLinks in spec/readme-sections.json: a shield ends -shield, a URI ends -link, an in-page anchor and a repo-local path are bare. A destination every repo has carries the same name everywhere (github-link, actions-link, releases-link, commits-link, issues-link, discussions-link, docker-hub-link, nuget-link, pypi-link), keyed to the repo's own URLs so a third-party link keeps its descriptive name, and a repo publishing several of something prefixes the target rather than renaming (nxmeta-docker-hub-link). Grouping is reported as drift rather than letter, and covers the closed five-group set (Sections, Shields, Distribution, Repo, External), their order, sorting by reference name within a group, and a reference sitting in the group its kind implies.", "intentRef": "spec/readme-structure.md" }, + { "id": "readme.tools", "verdict": "letter", "assert": "A 3rd Party Tools entry for a tool the fleet catalog names matches that catalog, per spec/third-party-tools.json: the same link and the same description, and the description says what the tool is rather than what this repo does with it. The catalog is a standard set rather than a complete one, so a tool it does not name produces no finding, and adding a tool to it is a hub edit. The section carries no license column.", "intentRef": "spec/readme-structure.md" }, + { "id": "readme.tagline", "verdict": "letter", "assert": "The first line after the H1 is the tagline: one sentence, link-free plain text, at most 100 characters, mirrored to the GitHub About description, the HISTORY.md opening, and the Docker Hub short description. Further paragraphs below it are free prose under no length or link rule, and no mirror carries them.", "intentRef": "spec/readme-structure.md" } + ] + } + } +} diff --git a/spec/project-types.schema.json b/spec/project-types.schema.json index 6e29ab23..b1e92b71 100644 --- a/spec/project-types.schema.json +++ b/spec/project-types.schema.json @@ -1,38 +1,38 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/ptr727/ProjectTemplate/spec/project-types.schema.json", - "title": "Project types and cross-cutting dimensions", - "type": "object", - "required": ["types", "crossCutting"], - "properties": { - "$schema": { "type": "string" }, - "note": { "type": "string" }, - "types": { "type": "object", "additionalProperties": { "$ref": "#/$defs/typeDef" } }, - "crossCutting": { "type": "object", "additionalProperties": { "$ref": "#/$defs/typeDef" } } - }, - "$defs": { - "check": { - "type": "object", - "required": ["id", "verdict", "assert"], - "additionalProperties": false, - "properties": { - "id": { "type": "string" }, - "verdict": { "enum": ["letter", "intent"] }, - "assert": { "type": "string" }, - "intentRef": { "type": "string" }, - "workflowRef": { "type": "string" }, - "minProfile": { "enum": ["lint-only", "build"] } - } - }, - "typeDef": { - "type": "object", - "additionalProperties": true, - "properties": { - "checks": { "type": "array", "items": { "$ref": "#/$defs/check" } }, - "profiles": { "type": "array", "items": { "enum": ["lint-only", "build"] } }, - "appliesTo": { "type": ["string", "array"] }, - "priority": { "enum": ["high", "normal"] } - } - } - } -} +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/ptr727/ProjectTemplate/spec/project-types.schema.json", + "title": "Project types and cross-cutting dimensions", + "type": "object", + "required": ["types", "crossCutting"], + "properties": { + "$schema": { "type": "string" }, + "note": { "type": "string" }, + "types": { "type": "object", "additionalProperties": { "$ref": "#/$defs/typeDef" } }, + "crossCutting": { "type": "object", "additionalProperties": { "$ref": "#/$defs/typeDef" } } + }, + "$defs": { + "check": { + "type": "object", + "required": ["id", "verdict", "assert"], + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "verdict": { "enum": ["letter", "intent"] }, + "assert": { "type": "string" }, + "intentRef": { "type": "string" }, + "workflowRef": { "type": "string" }, + "minProfile": { "enum": ["lint-only", "build"] } + } + }, + "typeDef": { + "type": "object", + "additionalProperties": true, + "properties": { + "checks": { "type": "array", "items": { "$ref": "#/$defs/check" } }, + "profiles": { "type": "array", "items": { "enum": ["lint-only", "build"] } }, + "appliesTo": { "type": ["string", "array"] }, + "priority": { "enum": ["high", "normal"] } + } + } + } +} diff --git a/spec/readme-sections.json b/spec/readme-sections.json index ee982aa9..ab8050c4 100644 --- a/spec/readme-sections.json +++ b/spec/readme-sections.json @@ -1,86 +1,86 @@ -{ - "$schema": "./readme-sections.schema.json", - "note": "The declared README section and shield model the audit's readme-structure dimension checks mechanically. Ordinals match the numbered items in spec/readme-structure.md, which is the prose the model encodes; item 1 is the title block rather than a level-two heading, so the section list starts at ordinal 2. The order check drops every heading absent from this list, so a genuinely repo-specific section may sit anywhere, then asserts the surviving ordinals are non-decreasing, every required section is present, and the section carrying last:true is the final level-two heading in the file. Requirement is always, public (a public repository only), or optional; an optional section that is present still holds its ordinal. A section naming notApplicableTo is exempt where the repository carries one of those project types. Shield classes are additive: the base class applies to every repository and each remaining class adds its shields where its trigger fires. A shield is matched on its shields.io endpoint path rather than on its alt text or reference name, because those are captions a repository writes to suit itself and the fleet spells the same badge Release Status, Releases Build, Build Status, Workflow Status and Lint Build against one endpoint, while every one of them renders from /github/actions/workflow/status/. requireQuery and forbidQuery separate two shields sharing an endpoint, which is how the GitHub release and pre-release versions are told apart. Each shield names the section it belongs in, as a heading or a heading and sub-heading, so where a shield lives is data rather than a fixed field name: that is what lets the license shield be an ordinary shield sitting in a different section instead of a second model beside this one. A shield marked exclusive must appear nowhere outside the section it names, which the license shield is the only one to need. A class states a floor rather than a closed set, so an extra shield is never a finding. linkGroups is the closed, ordered set of reference-definition groups at the foot of the file, each holding one kind, and linkNaming gives the suffix a reference name carries for the kind it points at. canonicalLinks fixes the reference name for a destination every repository has, so the same thing is called the same thing everywhere and a reader moving between repositories is not re-learning the names: it is keyed to the repository's own URLs, so a link to somebody else's GitHub repository is an ordinary external link. A destination marked perTarget is one a repository may publish more than one of, and it takes the bare canonical name where there is exactly one and a target-qualified name where there are several, so NxWitness writes nxmeta-docker-hub-link and nxwitness-lsio-docker-hub-link where PlexCleaner writes docker-hub-link. The qualifier is a prefix rather than a new name, which is the whole point: one shape covers one image and twelve.", - "sections": [ - { "name": "Build and Distribution", "ordinal": 2, "required": "always", "subsections": ["Build Status", "Releases", "Release Notes"] }, - { "name": "Getting Started", "ordinal": 3, "required": "optional" }, - { "name": "Table of Contents", "ordinal": 4, "required": "always" }, - { "name": "Overview", "ordinal": 5, "required": "optional", "retiredNames": ["Use Cases", "Features", "About"] }, - { "name": "Installation", "ordinal": 6, "required": "optional", "notApplicableTo": ["source-only"] }, - { "name": "Configuration", "ordinal": 7, "required": "optional" }, - { "name": "Usage", "ordinal": 8, "required": "optional", "notApplicableTo": ["source-only"] }, - { "name": "Questions or Issues", "ordinal": 9, "required": "public" }, - { "name": "Development Environment Setup", "ordinal": 10, "required": "optional" }, - { "name": "Contributing", "ordinal": 11, "required": "optional" }, - { "name": "3rd Party Tools", "ordinal": 12, "required": "always" }, - { "name": "Credits", "ordinal": 13, "required": "optional" }, - { "name": "License", "ordinal": 14, "required": "always", "last": true } - ], - "directiveComments": ["markdownlint", "prettier-ignore", "cspell:", "editorconfig-checker-", "omit from toc"], - "linkGroups": [ - { "name": "Sections", "holds": "anchor", "note": "In-page anchors, which the auto-generated Table of Contents does not use since it keeps inline links." }, - { "name": "Shields", "holds": "shield", "note": "Badges, meaning any reference the document renders as an image, whatever host serves it." }, - { "name": "Distribution", "holds": "distribution", "note": "This project's own presence on a platform: its GitHub repository and the pages under it, its Docker Hub images, its NuGet packages, its PyPI packages. Separate from External because a reader looking for where the project ships should not have to pick it out of a list of third-party URLs." }, - { "name": "Repo", "holds": "local", "note": "Files and paths inside this repository." }, - { "name": "External", "holds": "external", "note": "Every other URI, meaning anything that is not this project." } - ], - "linkNaming": [ - { "kind": "shield", "suffix": "-shield", "note": "A badge, judged by the document rendering the reference as an image rather than by the host serving it." }, - { "kind": "distribution", "suffix": "-link", "note": "A URI." }, - { "kind": "external", "suffix": "-link", "note": "A URI." }, - { "kind": "anchor", "suffix": "", "note": "An in-page anchor is a bare reference." }, - { "kind": "local", "suffix": "", "note": "A path inside this repository is a bare reference." } - ], - "distribution": { - "note": "The URL prefixes that make a link this project's own rather than somebody else's, with {slug} the owner/repo pair and {owner} the owner alone. This is the single definition of ownership: link_kind reads it to classify a reference, and a canonicalLinks entry is only ever consulted for a reference already classified distribution. Keeping the two apart matters, because a 3rd Party Tools list is full of other people's GitHub repositories, Docker Hub images and NuGet packages, and a host-only test would rename Serilog's package link to nuget-link. The package prefixes carry the owner because a fleet package is published under the owner's own name, which is what distinguishes it from a dependency on the same host.", - "urlPrefixes": [ - "https://github.com/{slug}", - "https://hub.docker.com/r/{owner}/", - "https://www.nuget.org/packages/{owner}.", - "https://nuget.org/packages/{owner}.", - "https://pypi.org/project/{owner}-" - ] - }, - "canonicalLinks": [ - { "name": "github-link", "repoPath": "" }, - { "name": "actions-link", "repoPath": "/actions" }, - { "name": "releases-link", "repoPath": "/releases" }, - { "name": "commits-link", "repoPath": "/commits", "prefix": true }, - { "name": "issues-link", "repoPath": "/issues" }, - { "name": "discussions-link", "repoPath": "/discussions" }, - { "name": "docker-hub-link", "match": "^https://hub\\.docker\\.com/r/", "perTarget": true }, - { "name": "nuget-link", "match": "^https://(www\\.)?nuget\\.org/packages/", "perTarget": true }, - { "name": "pypi-link", "match": "^https://pypi\\.org/project/", "perTarget": true } - ], - "deprecatedShields": [ - { "match": "byob.yarr.is", "label": "the byob.yarr.is last-build badge", "reason": "The service is deprecated, and two of the three repositories carrying it already render a broken badge, since the endpoint returns 404. No shield class requires a last-build badge, so the fix is to delete the shield and its reference definition rather than to replace it." } - ], - "shieldClasses": [ - { - "name": "base", - "trigger": { "kind": "always" }, - "shields": [ - { "label": "build status", "match": "/github/actions/workflow/status/", "in": "Build and Distribution > Build Status" }, - { "label": "last commit", "match": "/github/last-commit/", "in": "Build and Distribution > Build Status" }, - { "label": "GitHub release version", "match": "/github/v/release/", "forbidQuery": "include_prereleases", "in": "Build and Distribution > Releases" }, - { "label": "GitHub pre-release version", "match": "/github/v/release/", "requireQuery": "include_prereleases", "in": "Build and Distribution > Releases" }, - { "label": "license", "match": "/github/license/", "in": "License", "exclusive": true } - ] - }, - { - "name": "docker", - "trigger": { "kind": "publish", "target": "docker" }, - "shields": [{ "label": "Docker image version", "match": "/docker/v/", "in": "Build and Distribution > Releases" }] - }, - { - "name": "nuget", - "trigger": { "kind": "publish", "target": "nuget" }, - "shields": [{ "label": "NuGet release version", "match": "/nuget/v/", "in": "Build and Distribution > Releases" }] - }, - { - "name": "pypi", - "trigger": { "kind": "publish", "target": "pypi" }, - "shields": [{ "label": "PyPI release version", "match": "/pypi/v/", "in": "Build and Distribution > Releases" }] - } - ] -} +{ + "$schema": "./readme-sections.schema.json", + "note": "The declared README section and shield model the audit's readme-structure dimension checks mechanically. Ordinals match the numbered items in spec/readme-structure.md, which is the prose the model encodes; item 1 is the title block rather than a level-two heading, so the section list starts at ordinal 2. The order check drops every heading absent from this list, so a genuinely repo-specific section may sit anywhere, then asserts the surviving ordinals are non-decreasing, every required section is present, and the section carrying last:true is the final level-two heading in the file. Requirement is always, public (a public repository only), or optional; an optional section that is present still holds its ordinal. A section naming notApplicableTo is exempt where the repository carries one of those project types. Shield classes are additive: the base class applies to every repository and each remaining class adds its shields where its trigger fires. A shield is matched on its shields.io endpoint path rather than on its alt text or reference name, because those are captions a repository writes to suit itself and the fleet spells the same badge Release Status, Releases Build, Build Status, Workflow Status and Lint Build against one endpoint, while every one of them renders from /github/actions/workflow/status/. requireQuery and forbidQuery separate two shields sharing an endpoint, which is how the GitHub release and pre-release versions are told apart. Each shield names the section it belongs in, as a heading or a heading and sub-heading, so where a shield lives is data rather than a fixed field name: that is what lets the license shield be an ordinary shield sitting in a different section instead of a second model beside this one. A shield marked exclusive must appear nowhere outside the section it names, which the license shield is the only one to need. A class states a floor rather than a closed set, so an extra shield is never a finding. linkGroups is the closed, ordered set of reference-definition groups at the foot of the file, each holding one kind, and linkNaming gives the suffix a reference name carries for the kind it points at. canonicalLinks fixes the reference name for a destination every repository has, so the same thing is called the same thing everywhere and a reader moving between repositories is not re-learning the names: it is keyed to the repository's own URLs, so a link to somebody else's GitHub repository is an ordinary external link. A destination marked perTarget is one a repository may publish more than one of, and it takes the bare canonical name where there is exactly one and a target-qualified name where there are several, so NxWitness writes nxmeta-docker-hub-link and nxwitness-lsio-docker-hub-link where PlexCleaner writes docker-hub-link. The qualifier is a prefix rather than a new name, which is the whole point: one shape covers one image and twelve.", + "sections": [ + { "name": "Build and Distribution", "ordinal": 2, "required": "always", "subsections": ["Build Status", "Releases", "Release Notes"] }, + { "name": "Getting Started", "ordinal": 3, "required": "optional" }, + { "name": "Table of Contents", "ordinal": 4, "required": "always" }, + { "name": "Overview", "ordinal": 5, "required": "optional", "retiredNames": ["Use Cases", "Features", "About"] }, + { "name": "Installation", "ordinal": 6, "required": "optional", "notApplicableTo": ["source-only"] }, + { "name": "Configuration", "ordinal": 7, "required": "optional" }, + { "name": "Usage", "ordinal": 8, "required": "optional", "notApplicableTo": ["source-only"] }, + { "name": "Questions or Issues", "ordinal": 9, "required": "public" }, + { "name": "Development Environment Setup", "ordinal": 10, "required": "optional" }, + { "name": "Contributing", "ordinal": 11, "required": "optional" }, + { "name": "3rd Party Tools", "ordinal": 12, "required": "always" }, + { "name": "Credits", "ordinal": 13, "required": "optional" }, + { "name": "License", "ordinal": 14, "required": "always", "last": true } + ], + "directiveComments": ["markdownlint", "prettier-ignore", "cspell:", "editorconfig-checker-", "omit from toc"], + "linkGroups": [ + { "name": "Sections", "holds": "anchor", "note": "In-page anchors, which the auto-generated Table of Contents does not use since it keeps inline links." }, + { "name": "Shields", "holds": "shield", "note": "Badges, meaning any reference the document renders as an image, whatever host serves it." }, + { "name": "Distribution", "holds": "distribution", "note": "This project's own presence on a platform: its GitHub repository and the pages under it, its Docker Hub images, its NuGet packages, its PyPI packages. Separate from External because a reader looking for where the project ships should not have to pick it out of a list of third-party URLs." }, + { "name": "Repo", "holds": "local", "note": "Files and paths inside this repository." }, + { "name": "External", "holds": "external", "note": "Every other URI, meaning anything that is not this project." } + ], + "linkNaming": [ + { "kind": "shield", "suffix": "-shield", "note": "A badge, judged by the document rendering the reference as an image rather than by the host serving it." }, + { "kind": "distribution", "suffix": "-link", "note": "A URI." }, + { "kind": "external", "suffix": "-link", "note": "A URI." }, + { "kind": "anchor", "suffix": "", "note": "An in-page anchor is a bare reference." }, + { "kind": "local", "suffix": "", "note": "A path inside this repository is a bare reference." } + ], + "distribution": { + "note": "The URL prefixes that make a link this project's own rather than somebody else's, with {slug} the owner/repo pair and {owner} the owner alone. This is the single definition of ownership: link_kind reads it to classify a reference, and a canonicalLinks entry is only ever consulted for a reference already classified distribution. Keeping the two apart matters, because a 3rd Party Tools list is full of other people's GitHub repositories, Docker Hub images and NuGet packages, and a host-only test would rename Serilog's package link to nuget-link. The package prefixes carry the owner because a fleet package is published under the owner's own name, which is what distinguishes it from a dependency on the same host.", + "urlPrefixes": [ + "https://github.com/{slug}", + "https://hub.docker.com/r/{owner}/", + "https://www.nuget.org/packages/{owner}.", + "https://nuget.org/packages/{owner}.", + "https://pypi.org/project/{owner}-" + ] + }, + "canonicalLinks": [ + { "name": "github-link", "repoPath": "" }, + { "name": "actions-link", "repoPath": "/actions" }, + { "name": "releases-link", "repoPath": "/releases" }, + { "name": "commits-link", "repoPath": "/commits", "prefix": true }, + { "name": "issues-link", "repoPath": "/issues" }, + { "name": "discussions-link", "repoPath": "/discussions" }, + { "name": "docker-hub-link", "match": "^https://hub\\.docker\\.com/r/", "perTarget": true }, + { "name": "nuget-link", "match": "^https://(www\\.)?nuget\\.org/packages/", "perTarget": true }, + { "name": "pypi-link", "match": "^https://pypi\\.org/project/", "perTarget": true } + ], + "deprecatedShields": [ + { "match": "byob.yarr.is", "label": "the byob.yarr.is last-build badge", "reason": "The service is deprecated, and two of the three repositories carrying it already render a broken badge, since the endpoint returns 404. No shield class requires a last-build badge, so the fix is to delete the shield and its reference definition rather than to replace it." } + ], + "shieldClasses": [ + { + "name": "base", + "trigger": { "kind": "always" }, + "shields": [ + { "label": "build status", "match": "/github/actions/workflow/status/", "in": "Build and Distribution > Build Status" }, + { "label": "last commit", "match": "/github/last-commit/", "in": "Build and Distribution > Build Status" }, + { "label": "GitHub release version", "match": "/github/v/release/", "forbidQuery": "include_prereleases", "in": "Build and Distribution > Releases" }, + { "label": "GitHub pre-release version", "match": "/github/v/release/", "requireQuery": "include_prereleases", "in": "Build and Distribution > Releases" }, + { "label": "license", "match": "/github/license/", "in": "License", "exclusive": true } + ] + }, + { + "name": "docker", + "trigger": { "kind": "publish", "target": "docker" }, + "shields": [{ "label": "Docker image version", "match": "/docker/v/", "in": "Build and Distribution > Releases" }] + }, + { + "name": "nuget", + "trigger": { "kind": "publish", "target": "nuget" }, + "shields": [{ "label": "NuGet release version", "match": "/nuget/v/", "in": "Build and Distribution > Releases" }] + }, + { + "name": "pypi", + "trigger": { "kind": "publish", "target": "pypi" }, + "shields": [{ "label": "PyPI release version", "match": "/pypi/v/", "in": "Build and Distribution > Releases" }] + } + ] +} diff --git a/spec/readme-sections.schema.json b/spec/readme-sections.schema.json index 169f5235..18f7c7bd 100644 --- a/spec/readme-sections.schema.json +++ b/spec/readme-sections.schema.json @@ -1,146 +1,146 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/ptr727/ProjectTemplate/spec/readme-sections.schema.json", - "title": "README section and shield model", - "type": "object", - "required": ["sections", "shieldClasses", "linkGroups", "linkNaming", "canonicalLinks", "distribution"], - "additionalProperties": false, - "properties": { - "$schema": { "type": "string" }, - "note": { "type": "string" }, - "sections": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "required": ["name", "ordinal", "required"], - "additionalProperties": false, - "properties": { - "name": { "type": "string", "minLength": 1 }, - "ordinal": { "type": "integer", "minimum": 1 }, - "required": { "enum": ["always", "public", "optional"] }, - "last": { "type": "boolean" }, - "subsections": { "type": "array", "items": { "type": "string", "minLength": 1 } }, - "retiredNames": { "type": "array", "items": { "type": "string", "minLength": 1 } }, - "notApplicableTo": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } } - } - } - }, - "directiveComments": { - "type": "array", - "items": { "type": "string", "minLength": 1, "description": "A comment prefix that instructs a tool rather than naming a link group." } - }, - "linkGroups": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "required": ["name", "holds"], - "additionalProperties": false, - "properties": { - "name": { "type": "string", "minLength": 1 }, - "holds": { "$ref": "#/$defs/linkKind" }, - "note": { "type": "string" } - } - } - }, - "linkNaming": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "required": ["kind", "suffix"], - "additionalProperties": false, - "properties": { - "kind": { "$ref": "#/$defs/linkKind" }, - "suffix": { "type": "string", "description": "The suffix a reference name carries for this kind. An empty string means a bare name." }, - "note": { "type": "string" } - } - } - }, - "distribution": { - "type": "object", - "required": ["urlPrefixes"], - "additionalProperties": false, - "properties": { - "note": { "type": "string" }, - "urlPrefixes": { - "type": "array", - "minItems": 1, - "items": { "type": "string", "minLength": 1, "description": "A URL prefix making a link the project's own. `{slug}` is owner/repo, `{owner}` is the owner." } - } - } - }, - "canonicalLinks": { - "type": "array", - "items": { - "type": "object", - "required": ["name"], - "anyOf": [{ "required": ["repoPath"] }, { "required": ["match"] }], - "additionalProperties": false, - "properties": { - "name": { "type": "string", "minLength": 1 }, - "repoPath": { "type": "string", "description": "Matched against the repository's own URL plus this path. An empty string is the repository root." }, - "match": { "type": "string", "minLength": 1, "description": "A regular expression, for a destination that is not under the repository URL." }, - "prefix": { "type": "boolean", "description": "Match repoPath as a prefix rather than exactly, for a path carrying a branch or a sub-path." }, - "perTarget": { "type": "boolean", "description": "The repository may publish more than one of these. One takes the bare name; several each take `<target>-<name>`." } - } - } - }, - "deprecatedShields": { - "type": "array", - "items": { - "type": "object", - "required": ["match", "label", "reason"], - "additionalProperties": false, - "properties": { - "match": { "type": "string", "minLength": 1, "description": "A substring of the badge URL." }, - "label": { "type": "string", "minLength": 1 }, - "reason": { "type": "string", "minLength": 1 } - } - } - }, - "shieldClasses": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "required": ["name", "trigger", "shields"], - "additionalProperties": false, - "properties": { - "name": { "type": "string", "minLength": 1 }, - "trigger": { - "type": "object", - "required": ["kind"], - "additionalProperties": false, - "properties": { - "kind": { "enum": ["always", "publish", "secret"] }, - "target": { "type": "string", "minLength": 1 }, - "name": { "type": "string", "minLength": 1 } - } - }, - "shields": { "$ref": "#/$defs/shieldList" } - } - } - } - }, - "$defs": { - "linkKind": { "enum": ["shield", "distribution", "external", "anchor", "local"] }, - "shieldList": { - "type": "array", - "items": { - "type": "object", - "required": ["label", "match", "in"], - "additionalProperties": false, - "properties": { - "label": { "type": "string", "minLength": 1 }, - "match": { "type": "string", "minLength": 1 }, - "in": { "type": "string", "minLength": 1, "description": "The section the shield belongs in, as `Heading` or `Heading > Sub-heading`." }, - "exclusive": { "type": "boolean", "description": "The shield must appear nowhere outside the section it names." }, - "requireQuery": { "type": "string", "minLength": 1 }, - "forbidQuery": { "type": "string", "minLength": 1 } - } - } - } - } -} +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/ptr727/ProjectTemplate/spec/readme-sections.schema.json", + "title": "README section and shield model", + "type": "object", + "required": ["sections", "shieldClasses", "linkGroups", "linkNaming", "canonicalLinks", "distribution"], + "additionalProperties": false, + "properties": { + "$schema": { "type": "string" }, + "note": { "type": "string" }, + "sections": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["name", "ordinal", "required"], + "additionalProperties": false, + "properties": { + "name": { "type": "string", "minLength": 1 }, + "ordinal": { "type": "integer", "minimum": 1 }, + "required": { "enum": ["always", "public", "optional"] }, + "last": { "type": "boolean" }, + "subsections": { "type": "array", "items": { "type": "string", "minLength": 1 } }, + "retiredNames": { "type": "array", "items": { "type": "string", "minLength": 1 } }, + "notApplicableTo": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } } + } + } + }, + "directiveComments": { + "type": "array", + "items": { "type": "string", "minLength": 1, "description": "A comment prefix that instructs a tool rather than naming a link group." } + }, + "linkGroups": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["name", "holds"], + "additionalProperties": false, + "properties": { + "name": { "type": "string", "minLength": 1 }, + "holds": { "$ref": "#/$defs/linkKind" }, + "note": { "type": "string" } + } + } + }, + "linkNaming": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["kind", "suffix"], + "additionalProperties": false, + "properties": { + "kind": { "$ref": "#/$defs/linkKind" }, + "suffix": { "type": "string", "description": "The suffix a reference name carries for this kind. An empty string means a bare name." }, + "note": { "type": "string" } + } + } + }, + "distribution": { + "type": "object", + "required": ["urlPrefixes"], + "additionalProperties": false, + "properties": { + "note": { "type": "string" }, + "urlPrefixes": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1, "description": "A URL prefix making a link the project's own. `{slug}` is owner/repo, `{owner}` is the owner." } + } + } + }, + "canonicalLinks": { + "type": "array", + "items": { + "type": "object", + "required": ["name"], + "anyOf": [{ "required": ["repoPath"] }, { "required": ["match"] }], + "additionalProperties": false, + "properties": { + "name": { "type": "string", "minLength": 1 }, + "repoPath": { "type": "string", "description": "Matched against the repository's own URL plus this path. An empty string is the repository root." }, + "match": { "type": "string", "minLength": 1, "description": "A regular expression, for a destination that is not under the repository URL." }, + "prefix": { "type": "boolean", "description": "Match repoPath as a prefix rather than exactly, for a path carrying a branch or a sub-path." }, + "perTarget": { "type": "boolean", "description": "The repository may publish more than one of these. One takes the bare name; several each take `<target>-<name>`." } + } + } + }, + "deprecatedShields": { + "type": "array", + "items": { + "type": "object", + "required": ["match", "label", "reason"], + "additionalProperties": false, + "properties": { + "match": { "type": "string", "minLength": 1, "description": "A substring of the badge URL." }, + "label": { "type": "string", "minLength": 1 }, + "reason": { "type": "string", "minLength": 1 } + } + } + }, + "shieldClasses": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["name", "trigger", "shields"], + "additionalProperties": false, + "properties": { + "name": { "type": "string", "minLength": 1 }, + "trigger": { + "type": "object", + "required": ["kind"], + "additionalProperties": false, + "properties": { + "kind": { "enum": ["always", "publish", "secret"] }, + "target": { "type": "string", "minLength": 1 }, + "name": { "type": "string", "minLength": 1 } + } + }, + "shields": { "$ref": "#/$defs/shieldList" } + } + } + } + }, + "$defs": { + "linkKind": { "enum": ["shield", "distribution", "external", "anchor", "local"] }, + "shieldList": { + "type": "array", + "items": { + "type": "object", + "required": ["label", "match", "in"], + "additionalProperties": false, + "properties": { + "label": { "type": "string", "minLength": 1 }, + "match": { "type": "string", "minLength": 1 }, + "in": { "type": "string", "minLength": 1, "description": "The section the shield belongs in, as `Heading` or `Heading > Sub-heading`." }, + "exclusive": { "type": "boolean", "description": "The shield must appear nowhere outside the section it names." }, + "requireQuery": { "type": "string", "minLength": 1 }, + "forbidQuery": { "type": "string", "minLength": 1 } + } + } + } + } +} diff --git a/spec/readme-structure.md b/spec/readme-structure.md index 3a8b1e4b..0a747129 100644 --- a/spec/readme-structure.md +++ b/spec/readme-structure.md @@ -1,108 +1,108 @@ -# README Structure - -The preferred `README.md` shape for a fleet project. The audit's `readme-structure` dimension checks a repo's README against this: the sections below, in this order (to the letter where a section applies, to intent where a project legitimately has nothing to say). A section that does not apply to a project type is N/A, not a defect (a library has no running-app "Installation", and a source-only repo has no "Usage"). - -The order, the requirement, and the shield sets are declared data in [`readme-sections.json`][readme-sections], which is what the audit reads. This file is the prose that model encodes, and the two are edited together. The model constrains the sections it names and nothing else: a heading it does not name is dropped before the order comparison, so a genuinely repo-specific section sits wherever the project wants it, per the recurrence rule in [`section-model.md`][section-model]. - -## Sections and Order - -1. **Title (`# <Name>`)** - the H1 **is the repository name** (a hyphenated name may render its hyphens as spaces: `Financial-Modeling` -> `Financial Modeling`), then the **tagline** as the first line of the next paragraph. The tagline is a **single sentence, link-free plain text, at most 100 characters**, and it alone is the canonical short description: it carries to the GitHub About description (GOVERNANCE.md "Repository Details"), to the `HISTORY.md` opening, and, for a repo that publishes a Docker image, to the Docker Hub short description. Those surfaces render no Markdown, and Docker Hub caps the short description near 100 characters, the tightest surface, which sets the limit. **Further paragraphs below the tagline are free prose**, carrying whatever a reader needs to understand the project before the fold, under no length or link rule and read by no mirror. The rule is scoped to the one line the mirrors take precisely so that a README is not forbidden from saying anything more about itself above the fold. The audit checks the H1 name, the tagline's length and link-free form, and the mirrors. -2. **Build and Distribution (`##`)** - a bullet per distribution channel the project actually ships, each linking where it lives: **Source Code** (the GitHub repo), **Versioned Releases** (GitHub Releases), **Docker Images** (Docker Hub), **NuGet Packages** (NuGet.org), **PyPI Packages** (PyPI.org). List only the channels the project uses, and name a channel for what it ships, so a repo publishing executables writes Binary Releases where one publishing source archives writes Versioned Releases. It carries three sub-sections: - - **Build Status (`###`)** - the CI and build status shields. - - **Releases (`###`)** - the version shields, one group per channel the project publishes, per "Shields" below. - - **Release Notes (`###`)** - the current version and a short summary, any breaking-change callout, then a link to `HISTORY.md` closing with the wording that it holds the complete release notes and older versions. -3. **Getting Started (`##`)** - *optional.* The shortest path to using the project. The **default is to omit it and go straight to the Table of Contents**, and include Getting Started only when it is short and genuinely helpful, and skip it when it would be long or complex. -4. **Table of Contents (`##`)** - **every README carries one**, with no size threshold, since navigability is the point and a threshold makes the shape depend on a length that changes. Generated by the Markdown All in One extension and auto-updated on save, so leave the `## Table of Contents` heading in place and let the extension fill and maintain the list. Its inline anchor links are the one exception to the reference-link rule. -5. **Overview (`##`)** - *optional.* What the project is and what problems it solves. **This heading has no accepted aliases**: `Use Cases`, `Features`, and `About` are the same slot under a different name, and the audit reports each as a rename. One name is chosen because four spellings of one section make the slot unrecognizable to a reader moving between repos, and `Overview` is the one that reads correctly for a configuration or hardware repo as well as for an application. -6. **Installation (`##`)** - how to install or pull it, per channel. N/A for source-only. -7. **Configuration (`##`)** - settings, environment, config files. N/A when there is nothing to configure. -8. **Usage (`##`)** - commands, API, or integration. For a CLI, sub-sections such as `### Command Quick Reference`, `### Global Options`, and a test/example command. N/A for source-only. -9. **Questions or Issues (`##`)** - where to file issues and ask questions. **Required in a public repo**, and optional in a private one, since a private repo has no audience to route. -10. **Development Environment Setup (`##`)** - *optional.* Build, test, and lint locally; commit-signing; devcontainer. Point at shared docs rather than restating them. -11. **Contributing (`##`)** - *optional.* The branching workflow, the code style, and the repository setup, each pointing at [`WORKFLOW.md`][workflow], [`CODESTYLE.md`][codestyle], and `repo-config/README.md` rather than restating them. -12. **3rd Party Tools (`##`)** - the notable dependencies, actions, and tools the project uses, alphabetized, each linked and paired with a **short description of what the tool is**. Required, since every project stands on something and a reader has no other place to find what. The section opens with one line, **"The third-party tools, libraries, and actions this project depends on."**, and that wording is the same in every repo, since a lead-in written per repo is a sentence about this repo's circumstances sitting where a reader expects a list. Each description is written as a sentence, opening with a capital and closing with a full stop. Describe the tool rather than this repo's use of it: "Spell checker." rather than "spell-checks `README.md` and `HISTORY.md` in CI", and "Version computation from git height." rather than a named config file. A role written against local wiring is a second copy of a fact that lives in the workflow, so it goes stale on its own schedule and reads as fleet divergence when the next repo wires the same tool differently. Carry no license column: a license belongs to the dependency and is authoritative at its source, so restating it here adds a maintenance obligation and no information. **A tool the fleet shares is named, linked, and described identically everywhere it appears**, from the catalog in [`third-party-tools.json`][third-party-tools], which is a standard set rather than a complete one: most of a repo's tools are its own and the audit says nothing about those, but a repo using a cataloged tool matches its link and its description. A tool becomes shared by being added to the catalog, which is a hub edit rather than a finding on a repo. -13. **Credits (`##`)** - *optional, and present when the project is directly based on someone else's work.* What it is based on and why it diverged, then which original copyright is retained, in `LICENSE` and `NOTICE`. -14. **License (`##`)** - **the last section in the file.** It states that the project is licensed under the MIT License, linking `LICENSE`, then carries the license shield, and the link definitions follow it. It closes the file because a license is what a reader checks last and never what they navigate to first. - -## Shields - -Shields live under **Build and Distribution**, as the **Build Status** and **Releases** sub-sections, with one exception: **the license shield sits in the closing License section**, below the license line. Each shield is a reference-style link, with the definitions grouped under `<!-- Shields -->` at the bottom of the file. Stack a group's badges by ending each line except the last with a trailing backslash. - -The sets are **additive by deliverable**. Every repo carries the base group, and each publishing channel adds its own on top, which is why the label is per-channel rather than one fixed list: - -| Class | Applies when | Build Status | Releases | -| --- | --- | --- | --- | -| Base | every repo | workflow status, last commit | GitHub release, GitHub pre-release | -| Docker | the repo publishes a Docker image | the Docker build status, where the image builds separately | a version shield per image and channel, `latest` and `develop` for a single-image repo | -| NuGet | the repo publishes to NuGet.org | none | the NuGet release version | -| PyPI | the repo publishes to PyPI.org | none | the PyPI release version | - -**NuGet and PyPI carry one version shield each, not a stable-and-prerelease pair**, and that asymmetry with GitHub and Docker is deliberate. A GitHub pre-release and a Docker `develop` tag are each a separately addressable channel a shield can point at. A NuGet or PyPI prerelease is a property of the version string with no separate address: shields.io publishes no PyPI prerelease badge at all, and its `nuget/vpre` endpoint renders the newest version *including* prereleases, which is the stable version whenever that is newest. A badge that duplicates the one beside it is worse than an absent one, because it reads as current. - -The audit matches a shield by its **shields.io endpoint**, never by its alt text or its reference name, because those are captions each repo writes to suit itself. A class states a floor rather than a closed set, so an extra shield is never a finding. - -A **retired badge service** is the one exception to that floor, and the audit reports it wherever it sits. A dead badge renders broken rather than absent, which reads as current to a visitor who has no way to tell a stale badge from a failing build. The retired list is `deprecatedShields` in [`readme-sections.json`][readme-sections], and it holds `byob.yarr.is`, whose last-build badge three repos still carry and two of them already serve as a 404. No class requires a last-build badge, so the fix is to delete the shield and its reference definition rather than to find a replacement. - -**Repository visibility does not change the structure.** A private repo carries the same sections and the same shield set as a public one, written in full. A private repo cannot be read by shields.io, so its GitHub-sourced badges render broken until it goes public, and that is accepted rather than worked around. The alternative, writing a reduced README while private and adding the missing shields at the visibility flip, makes the shape depend on a property that is temporary by design, since a repo is usually private precisely **while** it is being made presentable, which is exactly when its README is written. Carrying the full shape means going public is a visibility change and nothing else, with no README work owed at the flip and no second shape for the audit to accept. - -## Style - -- Write in the current state, present tense, so the reader needs no knowledge beyond what they are reading (GOVERNANCE.md Documentation Style). -- One logical paragraph per line, with no hard-wrap. -- Title-case headings with lowercase short bind words. -- US English and straight quotes. Judge a non-ASCII character against the three tiers in GOVERNANCE.md "Character Set", and restructure a sentence rather than joining its clauses with an em dash or with a spaced hyphen, which that section bans in the same terms. -- Verify every quantitative claim (counts, versions, supported platforms) against current code. -- A project README describes only that project, with no cross-repo references and no template or inheritance framing. -- Reference-style links only: every URI is a reference link defined at the bottom of the file, grouped and named per "Reference Links" below. The auto-generated Table of Contents is the one exception, keeping inline anchor links. - -## Reference Links - -Every reference definition lives at the foot of the file, in one of five groups, in this order, each introduced by an HTML-comment header. The set is closed: a group not on this list is drift, and the names are fixed so the block looks the same in every repo. The groups and the naming below are declared in [`readme-sections.json`][readme-sections], which is what the audit reads. - -| Group | Holds | -| --- | --- | -| `<!-- Sections -->` | In-page anchors. | -| `<!-- Shields -->` | Badges, meaning any reference the document renders as an image. | -| `<!-- Distribution -->` | This project's own presence on a platform: its GitHub repository and the pages under it, its Docker Hub images, its NuGet packages, its PyPI packages. | -| `<!-- Repo -->` | Files and paths inside this repository. | -| `<!-- External -->` | Every other URI, meaning anything that is not this project. | - -Distribution is separate from External because a reader looking for where the project ships should not have to pick those links out of a list of third-party URLs, and because they are the ones every repo has and can therefore name identically. - -**Within a group, definitions are sorted by the reference name alone**, the text inside the brackets, so a shorter name sorts above one it prefixes (`[governance]` above `[governance-branching-model]`) rather than below it as sorting the whole definition line would put it. - -**A reference name says what it points at, by its suffix:** - -- A **shield** ends `-shield`. This is judged by how the document uses the reference, not by which host serves it, so a badge from a host other than shields.io is still a shield. -- A **URI** ends `-link`. -- An **in-page anchor** and a **path inside this repository** are bare, with no suffix. - -**A destination every repository has carries the same name in every repository**, so the reference is `github-link` and never `plexcleaner-link` or `projecttemplate-link`. A reader moving between repos should not be re-learning names for the same six pages. The fixed names are `github-link` (the repository root), `actions-link`, `releases-link`, `commits-link`, `issues-link`, `discussions-link`, `docker-hub-link`, `nuget-link`, and `pypi-link`. This is keyed to the repository's own URLs, so a link to somebody else's GitHub repository or Docker Hub image, which a 3rd Party Tools list is full of, is an ordinary external link keeping its own descriptive name. - -**Where a repository publishes more than one of something, the name takes a target prefix rather than a new shape**: one Docker Hub image is `docker-hub-link`, and several are `nxmeta-docker-hub-link` and `nxwitness-lsio-docker-hub-link`. The same rule covers NuGet and PyPI. One shape covers a repo shipping one image and a repo shipping twelve, which is the point of prefixing rather than renaming. - -The audit reports a naming defect to the letter and a grouping defect as drift. That split is a measurement rather than a judgment about importance: 119 of 122 shield references across the fleet already end `-shield` and 514 of 532 URI references already end `-link`, so the naming rule is being written down rather than imposed, while the fleet carries seventeen distinct group-header names across twenty-two repos, and gating that at once would bury the naming findings under a re-grouping sweep. - -## TODO.md - -A repo's running backlog lives in a root `TODO.md`, and **a README carries no backlog section**. The README is the adoption surface a reader lands on, so open work does not belong in its section order, and the backlog changes on a different cadence from everything around it. `TODO.md` opens with a `# TODO` heading and an intro saying what it holds, then a bullet list, each item stating the work and the reason it is owed. That is the minimum rather than the shape, so a backlog long enough that selecting from it is itself work groups its items under headings and says on each what it costs, and a short one stays a flat list. This repo's own `TODO.md` is the worked example of the grouped form and is not a template every repo adopts, since a five-line backlog needs no selection procedure. - -The file is the declared destination rather than a required file, the same footing as `ARCHITECTURE.md`. A repo with no backlog carries no `TODO.md`, and a repo with one puts it here rather than inventing a location. Work already tracked as an issue belongs in the issue, so an entry here is either not yet filed or is the local pointer to a filed one. - -## HISTORY.md - -The rule lives in [`CODESTYLE.md`][codestyle] "Markdown and Spelling", which every repo carries, so a repo can read the rule it is measured against. This file states only what the audit does with it: the `readme-structure` dimension checks that the `HISTORY.md` title and **tagline** match the README's, with HTML comments stripped. The mirror is the tagline alone, so a README carrying further paragraphs below it does not repeat them in the changelog, which opens on its title, its one-line description, and then the releases. - -## Docker Hub README - -Docker Hub has two text fields: a **short description** (the tagline, capped near 100 characters) that mirrors the README tagline (item 1), and the longer **overview**. A repo that publishes a Docker image keeps a **separate** `Docker/README.md` for the overview: Docker Hub's description has a much smaller size limit than a project README, so it carries a trimmed overview, not the full README. It is published by the docker-readme workflow task, not copied from the root README. - -The short description reaches Docker Hub from the **GitHub About panel**, which the docker-readme task reads as `github.event.repository.description`, rather than from the README. That keeps a Markdown parser out of a publish job, and it means the README-to-About hop is the one a human or the audit closes, not CI. Where About has drifted from the README, the publish faithfully carries the drift onward, which is why the audit checks all three surfaces rather than only the two CI touches. - -<!-- Repo --> - -[codestyle]: ../CODESTYLE.md -[readme-sections]: ./readme-sections.json -[section-model]: ./section-model.md -[third-party-tools]: ./third-party-tools.json -[workflow]: ../WORKFLOW.md +# README Structure + +The preferred `README.md` shape for a fleet project. The audit's `readme-structure` dimension checks a repo's README against this: the sections below, in this order (to the letter where a section applies, to intent where a project legitimately has nothing to say). A section that does not apply to a project type is N/A, not a defect (a library has no running-app "Installation", and a source-only repo has no "Usage"). + +The order, the requirement, and the shield sets are declared data in [`readme-sections.json`][readme-sections], which is what the audit reads. This file is the prose that model encodes, and the two are edited together. The model constrains the sections it names and nothing else: a heading it does not name is dropped before the order comparison, so a genuinely repo-specific section sits wherever the project wants it, per the recurrence rule in [`section-model.md`][section-model]. + +## Sections and Order + +1. **Title (`# <Name>`)** - the H1 **is the repository name** (a hyphenated name may render its hyphens as spaces: `Financial-Modeling` -> `Financial Modeling`), then the **tagline** as the first line of the next paragraph. The tagline is a **single sentence, link-free plain text, at most 100 characters**, and it alone is the canonical short description: it carries to the GitHub About description (GOVERNANCE.md "Repository Details"), to the `HISTORY.md` opening, and, for a repo that publishes a Docker image, to the Docker Hub short description. Those surfaces render no Markdown, and Docker Hub caps the short description near 100 characters, the tightest surface, which sets the limit. **Further paragraphs below the tagline are free prose**, carrying whatever a reader needs to understand the project before the fold, under no length or link rule and read by no mirror. The rule is scoped to the one line the mirrors take precisely so that a README is not forbidden from saying anything more about itself above the fold. The audit checks the H1 name, the tagline's length and link-free form, and the mirrors. +2. **Build and Distribution (`##`)** - a bullet per distribution channel the project actually ships, each linking where it lives: **Source Code** (the GitHub repo), **Versioned Releases** (GitHub Releases), **Docker Images** (Docker Hub), **NuGet Packages** (NuGet.org), **PyPI Packages** (PyPI.org). List only the channels the project uses, and name a channel for what it ships, so a repo publishing executables writes Binary Releases where one publishing source archives writes Versioned Releases. It carries three sub-sections: + - **Build Status (`###`)** - the CI and build status shields. + - **Releases (`###`)** - the version shields, one group per channel the project publishes, per "Shields" below. + - **Release Notes (`###`)** - the current version and a short summary, any breaking-change callout, then a link to `HISTORY.md` closing with the wording that it holds the complete release notes and older versions. +3. **Getting Started (`##`)** - *optional.* The shortest path to using the project. The **default is to omit it and go straight to the Table of Contents**, and include Getting Started only when it is short and genuinely helpful, and skip it when it would be long or complex. +4. **Table of Contents (`##`)** - **every README carries one**, with no size threshold, since navigability is the point and a threshold makes the shape depend on a length that changes. Generated by the Markdown All in One extension and auto-updated on save, so leave the `## Table of Contents` heading in place and let the extension fill and maintain the list. Its inline anchor links are the one exception to the reference-link rule. +5. **Overview (`##`)** - *optional.* What the project is and what problems it solves. **This heading has no accepted aliases**: `Use Cases`, `Features`, and `About` are the same slot under a different name, and the audit reports each as a rename. One name is chosen because four spellings of one section make the slot unrecognizable to a reader moving between repos, and `Overview` is the one that reads correctly for a configuration or hardware repo as well as for an application. +6. **Installation (`##`)** - how to install or pull it, per channel. N/A for source-only. +7. **Configuration (`##`)** - settings, environment, config files. N/A when there is nothing to configure. +8. **Usage (`##`)** - commands, API, or integration. For a CLI, sub-sections such as `### Command Quick Reference`, `### Global Options`, and a test/example command. N/A for source-only. +9. **Questions or Issues (`##`)** - where to file issues and ask questions. **Required in a public repo**, and optional in a private one, since a private repo has no audience to route. +10. **Development Environment Setup (`##`)** - *optional.* Build, test, and lint locally; commit-signing; devcontainer. Point at shared docs rather than restating them. +11. **Contributing (`##`)** - *optional.* The branching workflow, the code style, and the repository setup, each pointing at [`WORKFLOW.md`][workflow], [`CODESTYLE.md`][codestyle], and `repo-config/README.md` rather than restating them. +12. **3rd Party Tools (`##`)** - the notable dependencies, actions, and tools the project uses, alphabetized, each linked and paired with a **short description of what the tool is**. Required, since every project stands on something and a reader has no other place to find what. The section opens with one line, **"The third-party tools, libraries, and actions this project depends on."**, and that wording is the same in every repo, since a lead-in written per repo is a sentence about this repo's circumstances sitting where a reader expects a list. Each description is written as a sentence, opening with a capital and closing with a full stop. Describe the tool rather than this repo's use of it: "Spell checker." rather than "spell-checks `README.md` and `HISTORY.md` in CI", and "Version computation from git height." rather than a named config file. A role written against local wiring is a second copy of a fact that lives in the workflow, so it goes stale on its own schedule and reads as fleet divergence when the next repo wires the same tool differently. Carry no license column: a license belongs to the dependency and is authoritative at its source, so restating it here adds a maintenance obligation and no information. **A tool the fleet shares is named, linked, and described identically everywhere it appears**, from the catalog in [`third-party-tools.json`][third-party-tools], which is a standard set rather than a complete one: most of a repo's tools are its own and the audit says nothing about those, but a repo using a cataloged tool matches its link and its description. A tool becomes shared by being added to the catalog, which is a hub edit rather than a finding on a repo. +13. **Credits (`##`)** - *optional, and present when the project is directly based on someone else's work.* What it is based on and why it diverged, then which original copyright is retained, in `LICENSE` and `NOTICE`. +14. **License (`##`)** - **the last section in the file.** It states that the project is licensed under the MIT License, linking `LICENSE`, then carries the license shield, and the link definitions follow it. It closes the file because a license is what a reader checks last and never what they navigate to first. + +## Shields + +Shields live under **Build and Distribution**, as the **Build Status** and **Releases** sub-sections, with one exception: **the license shield sits in the closing License section**, below the license line. Each shield is a reference-style link, with the definitions grouped under `<!-- Shields -->` at the bottom of the file. Stack a group's badges by ending each line except the last with a trailing backslash. + +The sets are **additive by deliverable**. Every repo carries the base group, and each publishing channel adds its own on top, which is why the label is per-channel rather than one fixed list: + +| Class | Applies when | Build Status | Releases | +| --- | --- | --- | --- | +| Base | every repo | workflow status, last commit | GitHub release, GitHub pre-release | +| Docker | the repo publishes a Docker image | the Docker build status, where the image builds separately | a version shield per image and channel, `latest` and `develop` for a single-image repo | +| NuGet | the repo publishes to NuGet.org | none | the NuGet release version | +| PyPI | the repo publishes to PyPI.org | none | the PyPI release version | + +**NuGet and PyPI carry one version shield each, not a stable-and-prerelease pair**, and that asymmetry with GitHub and Docker is deliberate. A GitHub pre-release and a Docker `develop` tag are each a separately addressable channel a shield can point at. A NuGet or PyPI prerelease is a property of the version string with no separate address: shields.io publishes no PyPI prerelease badge at all, and its `nuget/vpre` endpoint renders the newest version *including* prereleases, which is the stable version whenever that is newest. A badge that duplicates the one beside it is worse than an absent one, because it reads as current. + +The audit matches a shield by its **shields.io endpoint**, never by its alt text or its reference name, because those are captions each repo writes to suit itself. A class states a floor rather than a closed set, so an extra shield is never a finding. + +A **retired badge service** is the one exception to that floor, and the audit reports it wherever it sits. A dead badge renders broken rather than absent, which reads as current to a visitor who has no way to tell a stale badge from a failing build. The retired list is `deprecatedShields` in [`readme-sections.json`][readme-sections], and it holds `byob.yarr.is`, whose last-build badge three repos still carry and two of them already serve as a 404. No class requires a last-build badge, so the fix is to delete the shield and its reference definition rather than to find a replacement. + +**Repository visibility does not change the structure.** A private repo carries the same sections and the same shield set as a public one, written in full. A private repo cannot be read by shields.io, so its GitHub-sourced badges render broken until it goes public, and that is accepted rather than worked around. The alternative, writing a reduced README while private and adding the missing shields at the visibility flip, makes the shape depend on a property that is temporary by design, since a repo is usually private precisely **while** it is being made presentable, which is exactly when its README is written. Carrying the full shape means going public is a visibility change and nothing else, with no README work owed at the flip and no second shape for the audit to accept. + +## Style + +- Write in the current state, present tense, so the reader needs no knowledge beyond what they are reading (GOVERNANCE.md Documentation Style). +- One logical paragraph per line, with no hard-wrap. +- Title-case headings with lowercase short bind words. +- US English and straight quotes. Judge a non-ASCII character against the three tiers in GOVERNANCE.md "Character Set", and restructure a sentence rather than joining its clauses with an em dash or with a spaced hyphen, which that section bans in the same terms. +- Verify every quantitative claim (counts, versions, supported platforms) against current code. +- A project README describes only that project, with no cross-repo references and no template or inheritance framing. +- Reference-style links only: every URI is a reference link defined at the bottom of the file, grouped and named per "Reference Links" below. The auto-generated Table of Contents is the one exception, keeping inline anchor links. + +## Reference Links + +Every reference definition lives at the foot of the file, in one of five groups, in this order, each introduced by an HTML-comment header. The set is closed: a group not on this list is drift, and the names are fixed so the block looks the same in every repo. The groups and the naming below are declared in [`readme-sections.json`][readme-sections], which is what the audit reads. + +| Group | Holds | +| --- | --- | +| `<!-- Sections -->` | In-page anchors. | +| `<!-- Shields -->` | Badges, meaning any reference the document renders as an image. | +| `<!-- Distribution -->` | This project's own presence on a platform: its GitHub repository and the pages under it, its Docker Hub images, its NuGet packages, its PyPI packages. | +| `<!-- Repo -->` | Files and paths inside this repository. | +| `<!-- External -->` | Every other URI, meaning anything that is not this project. | + +Distribution is separate from External because a reader looking for where the project ships should not have to pick those links out of a list of third-party URLs, and because they are the ones every repo has and can therefore name identically. + +**Within a group, definitions are sorted by the reference name alone**, the text inside the brackets, so a shorter name sorts above one it prefixes (`[governance]` above `[governance-branching-model]`) rather than below it as sorting the whole definition line would put it. + +**A reference name says what it points at, by its suffix:** + +- A **shield** ends `-shield`. This is judged by how the document uses the reference, not by which host serves it, so a badge from a host other than shields.io is still a shield. +- A **URI** ends `-link`. +- An **in-page anchor** and a **path inside this repository** are bare, with no suffix. + +**A destination every repository has carries the same name in every repository**, so the reference is `github-link` and never `plexcleaner-link` or `projecttemplate-link`. A reader moving between repos should not be re-learning names for the same six pages. The fixed names are `github-link` (the repository root), `actions-link`, `releases-link`, `commits-link`, `issues-link`, `discussions-link`, `docker-hub-link`, `nuget-link`, and `pypi-link`. This is keyed to the repository's own URLs, so a link to somebody else's GitHub repository or Docker Hub image, which a 3rd Party Tools list is full of, is an ordinary external link keeping its own descriptive name. + +**Where a repository publishes more than one of something, the name takes a target prefix rather than a new shape**: one Docker Hub image is `docker-hub-link`, and several are `nxmeta-docker-hub-link` and `nxwitness-lsio-docker-hub-link`. The same rule covers NuGet and PyPI. One shape covers a repo shipping one image and a repo shipping twelve, which is the point of prefixing rather than renaming. + +The audit reports a naming defect to the letter and a grouping defect as drift. That split is a measurement rather than a judgment about importance: 119 of 122 shield references across the fleet already end `-shield` and 514 of 532 URI references already end `-link`, so the naming rule is being written down rather than imposed, while the fleet carries seventeen distinct group-header names across twenty-two repos, and gating that at once would bury the naming findings under a re-grouping sweep. + +## TODO.md + +A repo's running backlog lives in a root `TODO.md`, and **a README carries no backlog section**. The README is the adoption surface a reader lands on, so open work does not belong in its section order, and the backlog changes on a different cadence from everything around it. `TODO.md` opens with a `# TODO` heading and an intro saying what it holds, then a bullet list, each item stating the work and the reason it is owed. That is the minimum rather than the shape, so a backlog long enough that selecting from it is itself work groups its items under headings and says on each what it costs, and a short one stays a flat list. This repo's own `TODO.md` is the worked example of the grouped form and is not a template every repo adopts, since a five-line backlog needs no selection procedure. + +The file is the declared destination rather than a required file, the same footing as `ARCHITECTURE.md`. A repo with no backlog carries no `TODO.md`, and a repo with one puts it here rather than inventing a location. Work already tracked as an issue belongs in the issue, so an entry here is either not yet filed or is the local pointer to a filed one. + +## HISTORY.md + +The rule lives in [`CODESTYLE.md`][codestyle] "Markdown and Spelling", which every repo carries, so a repo can read the rule it is measured against. This file states only what the audit does with it: the `readme-structure` dimension checks that the `HISTORY.md` title and **tagline** match the README's, with HTML comments stripped. The mirror is the tagline alone, so a README carrying further paragraphs below it does not repeat them in the changelog, which opens on its title, its one-line description, and then the releases. + +## Docker Hub README + +Docker Hub has two text fields: a **short description** (the tagline, capped near 100 characters) that mirrors the README tagline (item 1), and the longer **overview**. A repo that publishes a Docker image keeps a **separate** `Docker/README.md` for the overview: Docker Hub's description has a much smaller size limit than a project README, so it carries a trimmed overview, not the full README. It is published by the docker-readme workflow task, not copied from the root README. + +The short description reaches Docker Hub from the **GitHub About panel**, which the docker-readme task reads as `github.event.repository.description`, rather than from the README. That keeps a Markdown parser out of a publish job, and it means the README-to-About hop is the one a human or the audit closes, not CI. Where About has drifted from the README, the publish faithfully carries the drift onward, which is why the audit checks all three surfaces rather than only the two CI touches. + +<!-- Repo --> + +[codestyle]: ../CODESTYLE.md +[readme-sections]: ./readme-sections.json +[section-model]: ./section-model.md +[third-party-tools]: ./third-party-tools.json +[workflow]: ../WORKFLOW.md diff --git a/spec/scope-model.md b/spec/scope-model.md index ae89fd42..50eb548c 100644 --- a/spec/scope-model.md +++ b/spec/scope-model.md @@ -1,46 +1,46 @@ -# Scope Model - -How every governance rule is scoped, so the carried docs are granular single-scope pieces composed per repo, not large pieces with internal carve-outs a reader must piece out. This is a hub-only doc: it governs the carrying machinery ([`spec/files.json`][files], [`spec/files.schema.json`][files-schema], [`spec/audit.py`][audit]) and is not itself carried to the fleet. - -## Two Axes - -A rule has a physical home, and a repo rule has a reach as well. - -- **Axis A, home.** A rule lives on the **host** (per-machine, `~/.claude`, `host-setup/`, loading in every session regardless of repo and covering ad-hoc work outside any project) or in the **repo** (it travels with a repo and can assume repo context). A rule that must hold in both places is stated in both and kept in sync deliberately, because the populations differ. The write-safety rules are the worked example, living in the host `~/.claude/CLAUDE.md` and the carried `AGENTS.md` at once. -- **Axis B, reach** (repo rules only). A repo rule is **hub-only** (meaningful only in this coordinator repo: the registry, the spec, the audit, fleet coordination), **all-downstream** (every derived repo), or **type-specific** (only repos matching a selector). Hub-only rules are simply absent from the carried baseline. All-downstream and type-specific rules are carried, gated by an `appliesTo` selector. - -## Selectors - -A selector is one token from one of four **disjoint** namespaces. Because the namespaces share no token, a single flat `appliesTo` list is unambiguous. - -| Namespace | Tokens | Source of truth | -| --- | --- | --- | -| project type | `csharp` `nuget` `pypi` `python` `cpp` `console` `docker` `homeassistant` `eda` `codegen` `upstream-wrapper` `source-only` `hugo` `docs` | [`spec/project-types.json`][project-types] | -| workflow model | `release` `operational` | [`registry/repos.schema.json`][repos-schema] | -| release trigger | `two-phase` `publish-on-merge` `dispatch-only` `none` | [`registry/repos.schema.json`][repos-schema] | -| consumer model | `push` `pull` | [`registry/repos.schema.json`][repos-schema] | - -A repo's **selector set** is its `types` plus its `workflowModel`, `releaseTrigger`, and `consumerModel`. `workflowModel` and `releaseTrigger` resolve as the repo value, then `defaults`, then the fleet default (`release`, `two-phase`). `consumerModel` has no fleet default. [`spec/validate.py`][validate] requires it on every cataloged repo, so a cataloged repo always contributes one. `validate.py` also enforces that every `appliesTo` token resolves to a known selector and that no project type collides with a reserved token, and [`spec/audit.py`][audit] resolves the set in `repo_selectors`. - -## appliesTo Semantics - -`appliesTo` appears on a [`spec/files.json`][files] entry (which files a repo carries) and, per the section-object form in [`spec/files.schema.json`][files-schema], on an individual `sections` element (which sections within a carried file apply). - -- **`*`** means all repos. -- A list is **disjunctive (any-of)**: `["csharp", "operational"]` reads "csharp OR operational". Cross-axis **AND is not expressible**, and that is deliberate. A single-scope piece carries one selector, so the need for AND is the signal to split the piece further, not to write a two-token entry. -- Entry-level and section-level `appliesTo` compose with **AND**: a section applies only if its file is carried by the repo *and* the section's own selector matches. - -## Documenting a Whole-Carried File's Section Scopes - -A file carried `whole` (no `sections` allowlist) still has single-scope sections, and the applicability gate resolves an inapplicable section to N/A at read time, so no split is needed. Record the mapping here rather than mechanizing it. - -- [`CODESTYLE.md`][codestyle]: **General** is all-downstream, **.NET** is `csharp`, **Python** is `python`. A non-`csharp` repo reads the .NET section as N/A, a non-`python` repo the Python section. - -<!-- Repo --> -[audit]: ./audit.py -[codestyle]: ../CODESTYLE.md -[files]: ./files.json -[files-schema]: ./files.schema.json -[project-types]: ./project-types.json -[repos-schema]: ../registry/repos.schema.json -[validate]: ./validate.py +# Scope Model + +How every governance rule is scoped, so the carried docs are granular single-scope pieces composed per repo, not large pieces with internal carve-outs a reader must piece out. This is a hub-only doc: it governs the carrying machinery ([`spec/files.json`][files], [`spec/files.schema.json`][files-schema], [`spec/audit.py`][audit]) and is not itself carried to the fleet. + +## Two Axes + +A rule has a physical home, and a repo rule has a reach as well. + +- **Axis A, home.** A rule lives on the **host** (per-machine, `~/.claude`, `host-setup/`, loading in every session regardless of repo and covering ad-hoc work outside any project) or in the **repo** (it travels with a repo and can assume repo context). A rule that must hold in both places is stated in both and kept in sync deliberately, because the populations differ. The write-safety rules are the worked example, living in the host `~/.claude/CLAUDE.md` and the carried `AGENTS.md` at once. +- **Axis B, reach** (repo rules only). A repo rule is **hub-only** (meaningful only in this coordinator repo: the registry, the spec, the audit, fleet coordination), **all-downstream** (every derived repo), or **type-specific** (only repos matching a selector). Hub-only rules are simply absent from the carried baseline. All-downstream and type-specific rules are carried, gated by an `appliesTo` selector. + +## Selectors + +A selector is one token from one of four **disjoint** namespaces. Because the namespaces share no token, a single flat `appliesTo` list is unambiguous. + +| Namespace | Tokens | Source of truth | +| --- | --- | --- | +| project type | `csharp` `nuget` `pypi` `python` `cpp` `console` `docker` `homeassistant` `eda` `codegen` `upstream-wrapper` `source-only` `hugo` `docs` | [`spec/project-types.json`][project-types] | +| workflow model | `release` `operational` | [`registry/repos.schema.json`][repos-schema] | +| release trigger | `two-phase` `publish-on-merge` `dispatch-only` `none` | [`registry/repos.schema.json`][repos-schema] | +| consumer model | `push` `pull` | [`registry/repos.schema.json`][repos-schema] | + +A repo's **selector set** is its `types` plus its `workflowModel`, `releaseTrigger`, and `consumerModel`. `workflowModel` and `releaseTrigger` resolve as the repo value, then `defaults`, then the fleet default (`release`, `two-phase`). `consumerModel` has no fleet default. [`spec/validate.py`][validate] requires it on every cataloged repo, so a cataloged repo always contributes one. `validate.py` also enforces that every `appliesTo` token resolves to a known selector and that no project type collides with a reserved token, and [`spec/audit.py`][audit] resolves the set in `repo_selectors`. + +## appliesTo Semantics + +`appliesTo` appears on a [`spec/files.json`][files] entry (which files a repo carries) and, per the section-object form in [`spec/files.schema.json`][files-schema], on an individual `sections` element (which sections within a carried file apply). + +- **`*`** means all repos. +- A list is **disjunctive (any-of)**: `["csharp", "operational"]` reads "csharp OR operational". Cross-axis **AND is not expressible**, and that is deliberate. A single-scope piece carries one selector, so the need for AND is the signal to split the piece further, not to write a two-token entry. +- Entry-level and section-level `appliesTo` compose with **AND**: a section applies only if its file is carried by the repo *and* the section's own selector matches. + +## Documenting a Whole-Carried File's Section Scopes + +A file carried `whole` (no `sections` allowlist) still has single-scope sections, and the applicability gate resolves an inapplicable section to N/A at read time, so no split is needed. Record the mapping here rather than mechanizing it. + +- [`CODESTYLE.md`][codestyle]: **General** is all-downstream, **.NET** is `csharp`, **Python** is `python`. A non-`csharp` repo reads the .NET section as N/A, a non-`python` repo the Python section. + +<!-- Repo --> +[audit]: ./audit.py +[codestyle]: ../CODESTYLE.md +[files]: ./files.json +[files-schema]: ./files.schema.json +[project-types]: ./project-types.json +[repos-schema]: ../registry/repos.schema.json +[validate]: ./validate.py diff --git a/spec/secrets.json b/spec/secrets.json index 722b156c..2b335529 100644 --- a/spec/secrets.json +++ b/spec/secrets.json @@ -1,64 +1,64 @@ -{ - "$schema": "./secrets.schema.json", - "note": "Secrets the audit cross-checks. `baseline` applies to every fleet repo (the App-signed merge-bot runs everywhere). `mechanisms` are per-target/per-feature additions: a repo requires the baseline plus the mechanisms its declared publish target (`targetMechanisms`) or declared type (`typeMechanisms`) maps to. All three mappings resolve from the registry entry rather than from workflow content: nothing reads a repo's Actions files to infer a mechanism, and `workflowNeeds` records what a mechanism needs to appear in a workflow for a human or agent reading the audit, rather than being a detector. `featureMechanisms` is shape-validated but claims nothing today, since the one feature it names (codecov) is claimed through `typeMechanisms` at build profile instead. Baseline secrets are implicit and are NOT repeated in a repo's registry `requiredSecrets`, which lists only the domain-specific additions. `typeMechanisms` are per-language requirements: a `csharp` or `python` repo must carry the mapped mechanism (codecov) regardless of opt-in. A configured secret that no applicable mechanism claims is a stale-secret finding; a present `forbids` secret is a defect. `environments`, where a repo carries it, lists the per-environment GitHub Environment secrets and variables its deploy needs. It is operator documentation rather than part of the mechanism audit: no tool reads it, because neither `spec/validate.py` nor `spec/audit.py` can enumerate an environment-scoped store, so a clean audit is not evidence that an environment is configured. `environmentSecrets` names what one environment carries and another does not, so a name audit does not read a single-environment credential as missing everywhere else.", - "baseline": { - "requires": ["CODEGEN_APP_CLIENT_ID", "CODEGEN_APP_PRIVATE_KEY"], - "forbids": ["CODEGEN_APP_ID"], - "workflowNeeds": ["actions/create-github-app-token"], - "stores": ["actions", "dependabot"], - "note": "The App-token secrets power the App-signed merge-bot (auto-merge that re-triggers downstream workflows), which every fleet repo runs; also consumed by codegen and the upstream-version tracker where present. Used via actions/create-github-app-token with the client-id input (not the deprecated app-id). The CODEGEN_* name is historical, not codegen-specific." - }, - "mechanisms": { - "nuget-oidc": { - "kind": "oidc", - "requires": ["NUGET_USERNAME"], - "forbids": ["NUGET_API_KEY"], - "workflowNeeds": ["NuGet/login", "id-token: write"], - "stores": ["actions", "dependabot"] - }, - "pypi-oidc": { - "kind": "oidc", - "requires": [], - "forbids": ["PYPI_API_TOKEN", "TWINE_PASSWORD"], - "workflowNeeds": ["environment: pypi", "id-token: write", "skip-existing: true"], - "stores": [] - }, - "docker-hub": { - "kind": "static-secret", - "requires": ["DOCKER_HUB_USERNAME", "DOCKER_HUB_ACCESS_TOKEN"], - "forbids": [], - "workflowNeeds": [], - "stores": ["actions", "dependabot"] - }, - "codecov": { - "kind": "static-secret", - "requires": ["CODECOV_TOKEN"], - "forbids": [], - "workflowNeeds": ["codecov/codecov-action"], - "stores": ["actions", "dependabot"], - "note": "A csharp or python repo lists CODECOV_TOKEN in its registry requiredSecrets (unlike the implicit baseline secrets). Both stores: a workflow run triggered by a Dependabot PR reads the Dependabot secrets store, so without that copy the coverage upload silently skips on every bot PR. Coverage upload is report-only by default (fail_ci_if_error: false, so a Codecov hiccup never fails the gate). A repo may enforce a stricter threshold (e.g. 99%+) on top of that default." - }, - "deploy-ssh": { - "kind": "static-secret", - "requires": [], - "forbids": [], - "workflowNeeds": ["environment:", "IdentitiesOnly=yes"], - "stores": [], - "note": "A deploy to a filesystem on a host the project owns, reached over SSH. requires and stores are empty deliberately rather than for want of credentials: the key and the host values are per-environment GitHub Environment secrets and variables, which this file has no vocabulary for and neither validate.py nor audit.py can enumerate. Listing the names would force them into the repo's registry requiredSecrets, which the audit resolves against the repository actions store, so a correctly configured repo would report every one of them as missing. A repo declares them in its own environments block below instead. The key is confined at the far end by an authorized_keys forced command rooted at the deploy tree, so the workflow names no host path." - } - }, - "targetMechanisms": { - "nuget": "nuget-oidc", - "pypi": "pypi-oidc", - "docker": "docker-hub", - "github-release": null, - "self-hosted": "deploy-ssh" - }, - "featureMechanisms": { - "codecov": "codecov" - }, - "typeMechanisms": { - "csharp": "codecov", - "python": "codecov" - } -} +{ + "$schema": "./secrets.schema.json", + "note": "Secrets the audit cross-checks. `baseline` applies to every fleet repo (the App-signed merge-bot runs everywhere). `mechanisms` are per-target/per-feature additions: a repo requires the baseline plus the mechanisms its declared publish target (`targetMechanisms`) or declared type (`typeMechanisms`) maps to. All three mappings resolve from the registry entry rather than from workflow content: nothing reads a repo's Actions files to infer a mechanism, and `workflowNeeds` records what a mechanism needs to appear in a workflow for a human or agent reading the audit, rather than being a detector. `featureMechanisms` is shape-validated but claims nothing today, since the one feature it names (codecov) is claimed through `typeMechanisms` at build profile instead. Baseline secrets are implicit and are NOT repeated in a repo's registry `requiredSecrets`, which lists only the domain-specific additions. `typeMechanisms` are per-language requirements: a `csharp` or `python` repo must carry the mapped mechanism (codecov) regardless of opt-in. A configured secret that no applicable mechanism claims is a stale-secret finding; a present `forbids` secret is a defect. `environments`, where a repo carries it, lists the per-environment GitHub Environment secrets and variables its deploy needs. It is operator documentation rather than part of the mechanism audit: no tool reads it, because neither `spec/validate.py` nor `spec/audit.py` can enumerate an environment-scoped store, so a clean audit is not evidence that an environment is configured. `environmentSecrets` names what one environment carries and another does not, so a name audit does not read a single-environment credential as missing everywhere else.", + "baseline": { + "requires": ["CODEGEN_APP_CLIENT_ID", "CODEGEN_APP_PRIVATE_KEY"], + "forbids": ["CODEGEN_APP_ID"], + "workflowNeeds": ["actions/create-github-app-token"], + "stores": ["actions", "dependabot"], + "note": "The App-token secrets power the App-signed merge-bot (auto-merge that re-triggers downstream workflows), which every fleet repo runs; also consumed by codegen and the upstream-version tracker where present. Used via actions/create-github-app-token with the client-id input (not the deprecated app-id). The CODEGEN_* name is historical, not codegen-specific." + }, + "mechanisms": { + "nuget-oidc": { + "kind": "oidc", + "requires": ["NUGET_USERNAME"], + "forbids": ["NUGET_API_KEY"], + "workflowNeeds": ["NuGet/login", "id-token: write"], + "stores": ["actions", "dependabot"] + }, + "pypi-oidc": { + "kind": "oidc", + "requires": [], + "forbids": ["PYPI_API_TOKEN", "TWINE_PASSWORD"], + "workflowNeeds": ["environment: pypi", "id-token: write", "skip-existing: true"], + "stores": [] + }, + "docker-hub": { + "kind": "static-secret", + "requires": ["DOCKER_HUB_USERNAME", "DOCKER_HUB_ACCESS_TOKEN"], + "forbids": [], + "workflowNeeds": [], + "stores": ["actions", "dependabot"] + }, + "codecov": { + "kind": "static-secret", + "requires": ["CODECOV_TOKEN"], + "forbids": [], + "workflowNeeds": ["codecov/codecov-action"], + "stores": ["actions", "dependabot"], + "note": "A csharp or python repo lists CODECOV_TOKEN in its registry requiredSecrets (unlike the implicit baseline secrets). Both stores: a workflow run triggered by a Dependabot PR reads the Dependabot secrets store, so without that copy the coverage upload silently skips on every bot PR. Coverage upload is report-only by default (fail_ci_if_error: false, so a Codecov hiccup never fails the gate). A repo may enforce a stricter threshold (e.g. 99%+) on top of that default." + }, + "deploy-ssh": { + "kind": "static-secret", + "requires": [], + "forbids": [], + "workflowNeeds": ["environment:", "IdentitiesOnly=yes"], + "stores": [], + "note": "A deploy to a filesystem on a host the project owns, reached over SSH. requires and stores are empty deliberately rather than for want of credentials: the key and the host values are per-environment GitHub Environment secrets and variables, which this file has no vocabulary for and neither validate.py nor audit.py can enumerate. Listing the names would force them into the repo's registry requiredSecrets, which the audit resolves against the repository actions store, so a correctly configured repo would report every one of them as missing. A repo declares them in its own environments block below instead. The key is confined at the far end by an authorized_keys forced command rooted at the deploy tree, so the workflow names no host path." + } + }, + "targetMechanisms": { + "nuget": "nuget-oidc", + "pypi": "pypi-oidc", + "docker": "docker-hub", + "github-release": null, + "self-hosted": "deploy-ssh" + }, + "featureMechanisms": { + "codecov": "codecov" + }, + "typeMechanisms": { + "csharp": "codecov", + "python": "codecov" + } +} diff --git a/spec/secrets.schema.json b/spec/secrets.schema.json index d3f6451d..5a34df64 100644 --- a/spec/secrets.schema.json +++ b/spec/secrets.schema.json @@ -1,65 +1,65 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/ptr727/ProjectTemplate/spec/secrets.schema.json", - "title": "Publish mechanism secrets", - "type": "object", - "required": ["baseline", "mechanisms", "targetMechanisms"], - "$defs": { - "secretSet": { - "type": "object", - "required": ["requires", "forbids"], - "additionalProperties": false, - "properties": { - "kind": { "enum": ["oidc", "static-secret"] }, - "requires": { "type": "array", "items": { "type": "string" } }, - "forbids": { "type": "array", "items": { "type": "string" } }, - "workflowNeeds": { "type": "array", "items": { "type": "string" } }, - "stores": { "type": "array", "items": { "enum": ["actions", "dependabot"] } }, - "note": { "type": "string" } - } - }, - "mechanism": { - "allOf": [ - { "$ref": "#/$defs/secretSet" }, - { "required": ["kind"] } - ] - } - }, - "properties": { - "$schema": { "type": "string" }, - "note": { "type": "string" }, - "baseline": { "$ref": "#/$defs/secretSet" }, - "mechanisms": { - "type": "object", - "additionalProperties": { "$ref": "#/$defs/mechanism" } - }, - "targetMechanisms": { - "type": "object", - "additionalProperties": { "type": ["string", "null"] } - }, - "featureMechanisms": { - "type": "object", - "additionalProperties": { "type": ["string", "null"] } - }, - "typeMechanisms": { - "type": "object", - "additionalProperties": { "type": ["string", "null"] } - }, - "environments": { - "type": "object", - "additionalProperties": false, - "properties": { - "note": { "type": "string" }, - "names": { "type": "array", "items": { "type": "string" } }, - "secrets": { "type": "array", "items": { "type": "string" } }, - "variables": { "type": "array", "items": { "type": "string" } }, - "secretsNote": { "type": "string" }, - "variablesNote": { "type": "string" }, - "environmentSecrets": { - "type": "object", - "additionalProperties": { "type": "array", "items": { "type": "string" } } - } - } - } - } -} +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/ptr727/ProjectTemplate/spec/secrets.schema.json", + "title": "Publish mechanism secrets", + "type": "object", + "required": ["baseline", "mechanisms", "targetMechanisms"], + "$defs": { + "secretSet": { + "type": "object", + "required": ["requires", "forbids"], + "additionalProperties": false, + "properties": { + "kind": { "enum": ["oidc", "static-secret"] }, + "requires": { "type": "array", "items": { "type": "string" } }, + "forbids": { "type": "array", "items": { "type": "string" } }, + "workflowNeeds": { "type": "array", "items": { "type": "string" } }, + "stores": { "type": "array", "items": { "enum": ["actions", "dependabot"] } }, + "note": { "type": "string" } + } + }, + "mechanism": { + "allOf": [ + { "$ref": "#/$defs/secretSet" }, + { "required": ["kind"] } + ] + } + }, + "properties": { + "$schema": { "type": "string" }, + "note": { "type": "string" }, + "baseline": { "$ref": "#/$defs/secretSet" }, + "mechanisms": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/mechanism" } + }, + "targetMechanisms": { + "type": "object", + "additionalProperties": { "type": ["string", "null"] } + }, + "featureMechanisms": { + "type": "object", + "additionalProperties": { "type": ["string", "null"] } + }, + "typeMechanisms": { + "type": "object", + "additionalProperties": { "type": ["string", "null"] } + }, + "environments": { + "type": "object", + "additionalProperties": false, + "properties": { + "note": { "type": "string" }, + "names": { "type": "array", "items": { "type": "string" } }, + "secrets": { "type": "array", "items": { "type": "string" } }, + "variables": { "type": "array", "items": { "type": "string" } }, + "secretsNote": { "type": "string" }, + "variablesNote": { "type": "string" }, + "environmentSecrets": { + "type": "object", + "additionalProperties": { "type": "array", "items": { "type": "string" } } + } + } + } + } +} diff --git a/spec/section-model.md b/spec/section-model.md index d782d93a..68a31206 100644 --- a/spec/section-model.md +++ b/spec/section-model.md @@ -1,103 +1,103 @@ -# Agent Instruction Section Model - -Companion to [fidelity-model.md][fidelity-model]. That doc defines how a carried *file* is verified. This one defines how the *sections* of the agent instruction set carry, and records the fidelity of each. It is the ground truth an agent or human consults before adding, removing, or re-typing a section, not a judgment re-derived each session. - -The set spans two files. [`AGENTS.md`][agents] is the entry point every agent reads first, and carries only the rules that apply to every task (context and delegation) plus the map to the rest. [`GOVERNANCE.md`][governance] carries the topical rule text, one section per topic. The split exists so an agent loads the section a task needs instead of the whole rule book, and it changes nothing about how a section is classified or checked. - -## Why sections have fidelity - -`GOVERNANCE.md` is the fleet's cross-cutting rule book, and its sections are not equal. Most state a rule that is identical in every repo. A few describe the repo they live in. Treating them all as carried-by-intent is what lets a rule silently paraphrase, weaken, or vanish, which is the recurring drift this model exists to stop. So each section carries a declared fidelity, checked by the audit against the hub canonical. - -## The categories - -A section is one of the following. Fidelity is declared in [files.json][files], never inferred. - -- **verbatim** - universal fleet-law rule *text*, byte-identical in every repo after EOL and action-pin normalization. The audit content-hashes each downstream copy against the hub's `## <heading>` block. A verbatim section may carry clauses only some repos exercise (for example "a source-only repo carries no build task"). The *text* is still identical everywhere, since applicability varies by repo while the wording does not. -- **intent** - the section *describes this particular repo* (its own directory tree, its own devcontainer and toolchain), so its content legitimately varies. The heading must be present, the body is judged by meaning rather than hashed. - -`appliesTo` scope is orthogonal to fidelity. A section may apply to a subset of repos (for example `operational`) and is checked only for repos in that subset. - -**The default is verbatim.** These files hold rule text, and rule text is universal. The repo-specific *values* live in other files (`.editorconfig`, `.devcontainer/`, the real tree), never in this prose. `intent` is the deliberate exception for a section that is inherently a description of one repo. - -## The classification - -| Section | File | Fidelity | Reason | -| --- | --- | --- | --- | -| Fleet Bootstrap | `AGENTS.md` | verbatim | names the hub and routes by the repo's actual state, so it must survive adaptation | -| Context and Delegation Discipline | `AGENTS.md` | verbatim | universal cost law: session scope, reading, commands, delegation | -| Where the Rules Live | `AGENTS.md` | verbatim | the map from a task to the section that governs it | -| Foundational Principles | `GOVERNANCE.md` | verbatim | the governing rationale, universal | -| Durable Knowledge and Self-Improvement | `GOVERNANCE.md` | verbatim | universal meta-rule: durable knowledge belongs in the committed docs and agents keep them current | -| Repository Boundaries and Write Safety | `GOVERNANCE.md` | verbatim | universal write-safety law | -| Representative Data in Agent-Authored Text | `GOVERNANCE.md` | verbatim | universal law on what agent-authored text may quote | -| Git and Commit Rules | `GOVERNANCE.md` | verbatim | universal git law | -| Branching Model | `GOVERNANCE.md` | verbatim | universal (repo-specific history SHAs removed so it can carry) | -| Release Model | `GOVERNANCE.md` | verbatim | universal release contract, all target shapes described inline | -| Operational Repositories | `GOVERNANCE.md` | verbatim | fleet context (the two workflow models), carried by all so the cross-references to it resolve | -| Hub-Hosted Tooling | `GOVERNANCE.md` | verbatim | universal law on what a repo carries against what it reaches, and how a hub tool is run | -| Pull Request Title and Commit Message Conventions | `GOVERNANCE.md` | verbatim | universal, with generic examples | -| Documentation Style Conventions | `GOVERNANCE.md` | verbatim | all rule text, including the Line Endings *rule* (a repo's `.editorconfig` value is not here) | -| Verification Discipline | `GOVERNANCE.md` | verbatim | universal verification law | -| PR Review Etiquette | `GOVERNANCE.md` | verbatim | universal review-loop contract | -| Communicating with the User | `GOVERNANCE.md` | verbatim | universal | -| Workflow YAML Conventions | `GOVERNANCE.md` | verbatim | universal conventions, applied to whatever workflows a repo has | -| Supported Development Platforms | `GOVERNANCE.md` | verbatim | universal | -| Editor and Tasks | `GOVERNANCE.md` | verbatim | universal editor convention (standard set plus per-language additions) | -| Repository Details | `GOVERNANCE.md` | verbatim | universal About-panel convention | -| Devcontainer | `GOVERNANCE.md` | intent | describes this repo's toolchain and devcontainer, genuinely per-repo | -| Repository Layout | `GOVERNANCE.md` | intent | describes this repo's directory tree, genuinely per-repo | - -**Devcontainer content.** A devcontainer is optional infrastructure, not required by any repo type. An operational (live config) repo is edited and deployed live and typically has none, so its Devcontainer section states that plainly. A repo that keeps one (a code repo's toolchain, or an offline-debugging aid for a config repo) describes it. The section is present in every carried `GOVERNANCE.md` so the development model is always answered, even when the answer is none. - -**Not carried (hub-only).** `Repository Onboarding and Conformance` lives in the hub's `GOVERNANCE.md` as hub-audit context (reconciling the registry, the STANDUP cold-start, the conformance matrix) but is not a carried section, since a downstream agent never runs those. Its one universal rule, that a repo is done when it passes `AUDIT.md` for its type, is carried in `AUDIT.md` itself. Like the model docs, `STANDUP.md` and `RESYNC.md`, it is hub machinery, not fleet law. - -## Changing the structure carries review weight - -The set of sections, and each section's fidelity, is itself governed. - -- **Adding a section** is a fleet-wide act, declaring a new rule every repo must carry. Add it to the file it belongs in, to `files.json` under that file, and to the table above in the same change, with its fidelity chosen deliberately. A rule that applies to every task belongs in `AGENTS.md`, and a topical rule belongs in `GOVERNANCE.md`. -- **Changing a verbatim section** re-vendors it across the whole fleet. The audit drift wave that follows is the mechanism working, not a regression. -- **Changing a section's fidelity** (intent to verbatim or back) is a governance decision, recorded here with its reason. -- **A downstream repo's extra section** the hub does not declare is drift to reconcile, not a local liberty, including a section whose *heading* differs but whose *content* duplicates a verbatim section (compare by content, not heading name). Either the rule belongs fleet-wide, so promote it here, or its unique part is repo-specific content that moves to one of the topical docs below and the duplicate is deleted. The audit lists a repo's undeclared sections as an advisory so the reconciliation is not missed. - -## Where repo-specific content goes - -A repo's own content is not carried, and the hub declares where it goes rather than what it says, so extraction needs a predictable destination rather than a judgment call per repo. Four topical docs take it, chosen by what the content *is*: - -- [`CODESTYLE.md`][codestyle]: a repo's language and formatting conventions beyond the carried rules. -- `ARCHITECTURE.md`: how a code repo is built, its module layout, data flow, and design decisions. -- `OPERATIONS.md`: how a repo is run, under the headings `Local Verification`, `Runbooks`, `Backup and Recovery`, `Logs and Debugging`, `Tool Usage`, and `Configuration Layout`. It is the operational analogue of `ARCHITECTURE.md`, and it is where an `AGENTS.md` split puts the repo-specific half. -- `TODO.md`: the repo's running backlog, which keeps open work out of the README's section order where it does not belong and changes on a different cadence from everything around it. - -**`OPERATIONS.md` is required for every repo**, declared in [`files.json`][files] as `appliesTo: "*"` and checked for presence only, the same footing as `README.md` and `HISTORY.md`, so its content is entirely the repo's own. It is mandatory rather than advisory because the convention was already emerging unevenly: of the four operational-model repos, two wrote one unprompted and the others scattered the same material across ad-hoc names, which is the improvisation these destinations exist to prevent. That reasoning never depended on the workflow model. Every repo has operational surface, since publishing to a package registry needs trusted-publisher setup, shipping an image needs registry credentials, and serving a site needs a deploy path and a staging story. A repo with nothing to say still carries the file as a stub, meaning those six headings with no content under them, because a stub names the destination and its shape where a blank file names only the destination. This repo's own [`OPERATIONS.md`][operations] is the worked example. - -**`Local Verification` leads because it is the only pre-merge heading.** It states what verifying a change in this repo requires, and specifically which part of the repo's contract CI structurally cannot exercise. Every other heading describes the repo after a change has landed, so a file ordered without it reads as post-merge from the first line down, and a pointer written from the file's most visible function then routes a pre-merge task elsewhere while describing the file accurately. That is the failure the heading exists to close, and it produces no signal, since the pipeline passes over the half it reaches and the routing description is not wrong. The content class recurs the way this list requires, because a runner reaches a build and not a redirect, a deploy, or a device, so every repo has some part of its contract that only a local run or a human covers. The heading is required for the same reason the file is, and a repo whose gates are entirely in CI says exactly that under it, since a stated *nothing beyond CI* is an answer a reader can act on where an absent heading is one they have to guess at. [GOVERNANCE.md][governance] "Verification Discipline" carries the reader's half of this, that the checks a change owes are read from what the repo declares rather than from what the pipeline happens to run. - -**The workflow model and the need for this file are unrelated axes.** `operational` as a `workflowModel` describes where config lives and how a change reaches `develop`, not whether the repo has runbooks. Keying the file to that selector read a sufficient condition as a necessary one, since an operational-model repo certainly has runbooks while a release repo has them too. Reclassifying a repo between models does not change how much operational surface it has, which is the test that showed the selector was wrong. - -**A destination is declared when its content class recurs across repos.** Software architecture recurs, because every code repo has one, so `ARCHITECTURE.md` is declared. A home-device inventory does not recur, so it stays the repo's own file, neither declared nor mandated. The scattering these destinations prevent is the same material landing under different names in different repos, and that has no force for content existing in exactly one repo. Declaring a destination for a one-repo need would grow this list without bound and still lag whatever the next repo invents. - -`CODESTYLE.md` is carried by every repo already. `ARCHITECTURE.md` stays **declared but advisory**: every code repo has an architecture, and how much of it earns a separate document is contextual, so mandating it would produce empty files where the design needs none. Declared and required are separate questions, and only a universal need answers both. - -`OPERATIONS.md` is agent-instruction content, so it takes the inline-link exception the Markdown rules name, not the reference-style default. `ARCHITECTURE.md` is not on that closed list and follows the reference-style rule. - -## Migrating a repo onto the split - -A repo that carried its governance inside `AGENTS.md` before the router split holds two things in one file: sections that are stale copies of fleet law, and local additions written after a fault the fleet has never seen. Re-vendoring the canonical over the whole file silently deletes the second kind. - -**Probe the canonical for each local rule's distinctive phrase.** That is the check that works. A word-overlap or similarity heuristic does not: a repo-specific rule written in ordinary governance vocabulary scores as a reworded duplicate of a rule it has nothing to do with, so the cheap check is confidently wrong in exactly the direction that loses content. Take each candidate rule, pick the phrasing that is peculiar to it, and grep the hub's canonical for that. Absent means it is a local addition, and it is then either promoted here or moved to the repo's topical doc, never dropped because a heuristic called it redundant. - -## Enforcement - -`files.json` declares each section's fidelity. [validate.py][validate] proves every declared section resolves to a real level-two heading in the hub's own copy of the file that declares it, so a renamed or mistyped section cannot silently stop being checked. [audit.py][audit] checks each repo's copy (presence for `intent`, byte-match for `verbatim`) and classifies a mismatch as stale (re-vendor) or modified (review). - -<!-- Internal --> - -[agents]: ../AGENTS.md -[audit]: ./audit.py -[codestyle]: ../CODESTYLE.md -[fidelity-model]: ./fidelity-model.md -[files]: ./files.json -[governance]: ../GOVERNANCE.md -[operations]: ../OPERATIONS.md -[validate]: ./validate.py +# Agent Instruction Section Model + +Companion to [fidelity-model.md][fidelity-model]. That doc defines how a carried *file* is verified. This one defines how the *sections* of the agent instruction set carry, and records the fidelity of each. It is the ground truth an agent or human consults before adding, removing, or re-typing a section, not a judgment re-derived each session. + +The set spans two files. [`AGENTS.md`][agents] is the entry point every agent reads first, and carries only the rules that apply to every task (context and delegation) plus the map to the rest. [`GOVERNANCE.md`][governance] carries the topical rule text, one section per topic. The split exists so an agent loads the section a task needs instead of the whole rule book, and it changes nothing about how a section is classified or checked. + +## Why sections have fidelity + +`GOVERNANCE.md` is the fleet's cross-cutting rule book, and its sections are not equal. Most state a rule that is identical in every repo. A few describe the repo they live in. Treating them all as carried-by-intent is what lets a rule silently paraphrase, weaken, or vanish, which is the recurring drift this model exists to stop. So each section carries a declared fidelity, checked by the audit against the hub canonical. + +## The categories + +A section is one of the following. Fidelity is declared in [files.json][files], never inferred. + +- **verbatim** - universal fleet-law rule *text*, byte-identical in every repo after EOL and action-pin normalization. The audit content-hashes each downstream copy against the hub's `## <heading>` block. A verbatim section may carry clauses only some repos exercise (for example "a source-only repo carries no build task"). The *text* is still identical everywhere, since applicability varies by repo while the wording does not. +- **intent** - the section *describes this particular repo* (its own directory tree, its own devcontainer and toolchain), so its content legitimately varies. The heading must be present, the body is judged by meaning rather than hashed. + +`appliesTo` scope is orthogonal to fidelity. A section may apply to a subset of repos (for example `operational`) and is checked only for repos in that subset. + +**The default is verbatim.** These files hold rule text, and rule text is universal. The repo-specific *values* live in other files (`.editorconfig`, `.devcontainer/`, the real tree), never in this prose. `intent` is the deliberate exception for a section that is inherently a description of one repo. + +## The classification + +| Section | File | Fidelity | Reason | +| --- | --- | --- | --- | +| Fleet Bootstrap | `AGENTS.md` | verbatim | names the hub and routes by the repo's actual state, so it must survive adaptation | +| Context and Delegation Discipline | `AGENTS.md` | verbatim | universal cost law: session scope, reading, commands, delegation | +| Where the Rules Live | `AGENTS.md` | verbatim | the map from a task to the section that governs it | +| Foundational Principles | `GOVERNANCE.md` | verbatim | the governing rationale, universal | +| Durable Knowledge and Self-Improvement | `GOVERNANCE.md` | verbatim | universal meta-rule: durable knowledge belongs in the committed docs and agents keep them current | +| Repository Boundaries and Write Safety | `GOVERNANCE.md` | verbatim | universal write-safety law | +| Representative Data in Agent-Authored Text | `GOVERNANCE.md` | verbatim | universal law on what agent-authored text may quote | +| Git and Commit Rules | `GOVERNANCE.md` | verbatim | universal git law | +| Branching Model | `GOVERNANCE.md` | verbatim | universal (repo-specific history SHAs removed so it can carry) | +| Release Model | `GOVERNANCE.md` | verbatim | universal release contract, all target shapes described inline | +| Operational Repositories | `GOVERNANCE.md` | verbatim | fleet context (the two workflow models), carried by all so the cross-references to it resolve | +| Hub-Hosted Tooling | `GOVERNANCE.md` | verbatim | universal law on what a repo carries against what it reaches, and how a hub tool is run | +| Pull Request Title and Commit Message Conventions | `GOVERNANCE.md` | verbatim | universal, with generic examples | +| Documentation Style Conventions | `GOVERNANCE.md` | verbatim | all rule text, including the Line Endings *rule* (a repo's `.editorconfig` value is not here) | +| Verification Discipline | `GOVERNANCE.md` | verbatim | universal verification law | +| PR Review Etiquette | `GOVERNANCE.md` | verbatim | universal review-loop contract | +| Communicating with the User | `GOVERNANCE.md` | verbatim | universal | +| Workflow YAML Conventions | `GOVERNANCE.md` | verbatim | universal conventions, applied to whatever workflows a repo has | +| Supported Development Platforms | `GOVERNANCE.md` | verbatim | universal | +| Editor and Tasks | `GOVERNANCE.md` | verbatim | universal editor convention (standard set plus per-language additions) | +| Repository Details | `GOVERNANCE.md` | verbatim | universal About-panel convention | +| Devcontainer | `GOVERNANCE.md` | intent | describes this repo's toolchain and devcontainer, genuinely per-repo | +| Repository Layout | `GOVERNANCE.md` | intent | describes this repo's directory tree, genuinely per-repo | + +**Devcontainer content.** A devcontainer is optional infrastructure, not required by any repo type. An operational (live config) repo is edited and deployed live and typically has none, so its Devcontainer section states that plainly. A repo that keeps one (a code repo's toolchain, or an offline-debugging aid for a config repo) describes it. The section is present in every carried `GOVERNANCE.md` so the development model is always answered, even when the answer is none. + +**Not carried (hub-only).** `Repository Onboarding and Conformance` lives in the hub's `GOVERNANCE.md` as hub-audit context (reconciling the registry, the STANDUP cold-start, the conformance matrix) but is not a carried section, since a downstream agent never runs those. Its one universal rule, that a repo is done when it passes `AUDIT.md` for its type, is carried in `AUDIT.md` itself. Like the model docs, `STANDUP.md` and `RESYNC.md`, it is hub machinery, not fleet law. + +## Changing the structure carries review weight + +The set of sections, and each section's fidelity, is itself governed. + +- **Adding a section** is a fleet-wide act, declaring a new rule every repo must carry. Add it to the file it belongs in, to `files.json` under that file, and to the table above in the same change, with its fidelity chosen deliberately. A rule that applies to every task belongs in `AGENTS.md`, and a topical rule belongs in `GOVERNANCE.md`. +- **Changing a verbatim section** re-vendors it across the whole fleet. The audit drift wave that follows is the mechanism working, not a regression. +- **Changing a section's fidelity** (intent to verbatim or back) is a governance decision, recorded here with its reason. +- **A downstream repo's extra section** the hub does not declare is drift to reconcile, not a local liberty, including a section whose *heading* differs but whose *content* duplicates a verbatim section (compare by content, not heading name). Either the rule belongs fleet-wide, so promote it here, or its unique part is repo-specific content that moves to one of the topical docs below and the duplicate is deleted. The audit lists a repo's undeclared sections as an advisory so the reconciliation is not missed. + +## Where repo-specific content goes + +A repo's own content is not carried, and the hub declares where it goes rather than what it says, so extraction needs a predictable destination rather than a judgment call per repo. Four topical docs take it, chosen by what the content *is*: + +- [`CODESTYLE.md`][codestyle]: a repo's language and formatting conventions beyond the carried rules. +- `ARCHITECTURE.md`: how a code repo is built, its module layout, data flow, and design decisions. +- `OPERATIONS.md`: how a repo is run, under the headings `Local Verification`, `Runbooks`, `Backup and Recovery`, `Logs and Debugging`, `Tool Usage`, and `Configuration Layout`. It is the operational analogue of `ARCHITECTURE.md`, and it is where an `AGENTS.md` split puts the repo-specific half. +- `TODO.md`: the repo's running backlog, which keeps open work out of the README's section order where it does not belong and changes on a different cadence from everything around it. + +**`OPERATIONS.md` is required for every repo**, declared in [`files.json`][files] as `appliesTo: "*"` and checked for presence only, the same footing as `README.md` and `HISTORY.md`, so its content is entirely the repo's own. It is mandatory rather than advisory because the convention was already emerging unevenly: of the four operational-model repos, two wrote one unprompted and the others scattered the same material across ad-hoc names, which is the improvisation these destinations exist to prevent. That reasoning never depended on the workflow model. Every repo has operational surface, since publishing to a package registry needs trusted-publisher setup, shipping an image needs registry credentials, and serving a site needs a deploy path and a staging story. A repo with nothing to say still carries the file as a stub, meaning those six headings with no content under them, because a stub names the destination and its shape where a blank file names only the destination. This repo's own [`OPERATIONS.md`][operations] is the worked example. + +**`Local Verification` leads because it is the only pre-merge heading.** It states what verifying a change in this repo requires, and specifically which part of the repo's contract CI structurally cannot exercise. Every other heading describes the repo after a change has landed, so a file ordered without it reads as post-merge from the first line down, and a pointer written from the file's most visible function then routes a pre-merge task elsewhere while describing the file accurately. That is the failure the heading exists to close, and it produces no signal, since the pipeline passes over the half it reaches and the routing description is not wrong. The content class recurs the way this list requires, because a runner reaches a build and not a redirect, a deploy, or a device, so every repo has some part of its contract that only a local run or a human covers. The heading is required for the same reason the file is, and a repo whose gates are entirely in CI says exactly that under it, since a stated *nothing beyond CI* is an answer a reader can act on where an absent heading is one they have to guess at. [GOVERNANCE.md][governance] "Verification Discipline" carries the reader's half of this, that the checks a change owes are read from what the repo declares rather than from what the pipeline happens to run. + +**The workflow model and the need for this file are unrelated axes.** `operational` as a `workflowModel` describes where config lives and how a change reaches `develop`, not whether the repo has runbooks. Keying the file to that selector read a sufficient condition as a necessary one, since an operational-model repo certainly has runbooks while a release repo has them too. Reclassifying a repo between models does not change how much operational surface it has, which is the test that showed the selector was wrong. + +**A destination is declared when its content class recurs across repos.** Software architecture recurs, because every code repo has one, so `ARCHITECTURE.md` is declared. A home-device inventory does not recur, so it stays the repo's own file, neither declared nor mandated. The scattering these destinations prevent is the same material landing under different names in different repos, and that has no force for content existing in exactly one repo. Declaring a destination for a one-repo need would grow this list without bound and still lag whatever the next repo invents. + +`CODESTYLE.md` is carried by every repo already. `ARCHITECTURE.md` stays **declared but advisory**: every code repo has an architecture, and how much of it earns a separate document is contextual, so mandating it would produce empty files where the design needs none. Declared and required are separate questions, and only a universal need answers both. + +`OPERATIONS.md` is agent-instruction content, so it takes the inline-link exception the Markdown rules name, not the reference-style default. `ARCHITECTURE.md` is not on that closed list and follows the reference-style rule. + +## Migrating a repo onto the split + +A repo that carried its governance inside `AGENTS.md` before the router split holds two things in one file: sections that are stale copies of fleet law, and local additions written after a fault the fleet has never seen. Re-vendoring the canonical over the whole file silently deletes the second kind. + +**Probe the canonical for each local rule's distinctive phrase.** That is the check that works. A word-overlap or similarity heuristic does not: a repo-specific rule written in ordinary governance vocabulary scores as a reworded duplicate of a rule it has nothing to do with, so the cheap check is confidently wrong in exactly the direction that loses content. Take each candidate rule, pick the phrasing that is peculiar to it, and grep the hub's canonical for that. Absent means it is a local addition, and it is then either promoted here or moved to the repo's topical doc, never dropped because a heuristic called it redundant. + +## Enforcement + +`files.json` declares each section's fidelity. [validate.py][validate] proves every declared section resolves to a real level-two heading in the hub's own copy of the file that declares it, so a renamed or mistyped section cannot silently stop being checked. [audit.py][audit] checks each repo's copy (presence for `intent`, byte-match for `verbatim`) and classifies a mismatch as stale (re-vendor) or modified (review). + +<!-- Internal --> + +[agents]: ../AGENTS.md +[audit]: ./audit.py +[codestyle]: ../CODESTYLE.md +[fidelity-model]: ./fidelity-model.md +[files]: ./files.json +[governance]: ../GOVERNANCE.md +[operations]: ../OPERATIONS.md +[validate]: ./validate.py diff --git a/spec/third-party-tools.json b/spec/third-party-tools.json index cba9525b..2ec9664e 100644 --- a/spec/third-party-tools.json +++ b/spec/third-party-tools.json @@ -1,46 +1,46 @@ -{ - "$schema": "./third-party-tools.schema.json", - "note": "The shared catalog behind a README's 3rd Party Tools section. A tool a repository uses is named, linked, and described the same way in every repository that uses it, so a reader moving between them recognizes it and a repository writing the section copies rather than invents. The description is written as a sentence, opening with a capital and closing with a full stop, and it says what the tool is, never what this repository does with it: a role written against local wiring is a second copy of a fact that lives in the workflow, it goes stale on its own schedule, and it reads as fleet divergence when the next repository wires the same tool differently. A tool used by one repository today is still declared here, because the point is that the second repository to adopt it finds the wording already written. This is a standard set rather than a complete one: a repository's tools are mostly its own, so a tool absent from this catalog is expected and the audit says nothing about it. What the audit checks is the intersection, meaning that a repository using a tool the catalog names matches the catalog's link and description for it. Extending the catalog is how a tool becomes shared, and it is a hub edit rather than a finding on a repository.", - "tools": [ - { "name": "7-Zip", "link": "https://www.7-zip.org/", "description": "File archiver." }, - { "name": "AwesomeAssertions", "link": "https://awesomeassertions.org/", "description": "Assertion library for .NET tests." }, - { "name": "Caddy", "link": "https://caddyserver.com", "description": "Web server." }, - { "name": "CliWrap", "link": "https://github.com/Tyrrrz/CliWrap", "description": "Process execution library for .NET." }, - { "name": "Create Pull Request", "link": "https://github.com/marketplace/actions/create-pull-request", "description": "GitHub action that opens a pull request from workspace changes." }, - { "name": "CSharpier", "link": "https://csharpier.com/", "description": "C# code formatter." }, - { "name": "cspell", "link": "https://cspell.org", "description": "Spell checker." }, - { "name": "Docker Hub Description", "link": "https://github.com/marketplace/actions/docker-hub-description", "description": "GitHub action that publishes a Docker Hub repository overview." }, - { "name": "Docker Run Action", "link": "https://github.com/marketplace/actions/docker-run-action", "description": "GitHub action that runs a step inside a container." }, - { "name": "dotnet-outdated", "link": "https://github.com/dotnet-outdated/dotnet-outdated", "description": "Outdated NuGet dependency reporter." }, - { "name": "editorconfig-checker", "link": "https://github.com/editorconfig-checker/editorconfig-checker", "description": "Line-ending and whitespace linter." }, - { "name": "ExifTool", "link": "https://exiftool.org/", "description": "Media metadata reader and writer." }, - { "name": "FFmpeg", "link": "https://www.ffmpeg.org/", "description": "Media transcoder." }, - { "name": "GH Release", "link": "https://github.com/marketplace/actions/gh-release", "description": "GitHub action that creates a release." }, - { "name": "Git Auto Commit", "link": "https://github.com/marketplace/actions/git-auto-commit", "description": "GitHub action that commits workspace changes." }, - { "name": "GitHub Actions", "link": "https://github.com/actions", "description": "CI and automation runner." }, - { "name": "GitHub Dependabot", "link": "https://github.com/dependabot", "description": "Dependency update bot." }, - { "name": "HandBrake", "link": "https://handbrake.fr/", "description": "Video transcoder." }, - { "name": "Hugo", "link": "https://gohugo.io", "description": "Static site generator." }, - { "name": "Husky.Net", "link": "https://alirezanet.github.io/Husky.Net/", "description": "Git hook manager for .NET." }, - { "name": "ISO 639-2 language tags", "link": "https://www.loc.gov/standards/iso639-2/langhome.html", "description": "Language code standard." }, - { "name": "ISO 639-3 language tags", "link": "https://iso639-3.sil.org/", "description": "Language code standard." }, - { "name": "JSON2CSharp", "link": "https://json2csharp.com", "description": "JSON to C# class generator." }, - { "name": "Markdown All in One", "link": "https://marketplace.visualstudio.com/items?itemName=yzhang.markdown-all-in-one", "description": "Markdown editing extension." }, - { "name": "markdownlint-cli2", "link": "https://github.com/DavidAnson/markdownlint-cli2", "description": "Markdown linter." }, - { "name": "MediaInfo", "link": "https://mediaarea.net/en-us/MediaInfo/", "description": "Media file inspector." }, - { "name": "MKVToolNix", "link": "https://mkvtoolnix.download/", "description": "Matroska container toolset." }, - { "name": "NEbml", "link": "https://github.com/OlegZee/NEbml", "description": "EBML reader and writer for .NET." }, - { "name": "Nerdbank.GitVersioning", "link": "https://github.com/dotnet/Nerdbank.GitVersioning", "description": "Version computation from git height." }, - { "name": "PaperMod", "link": "https://github.com/adityatelange/hugo-PaperMod", "description": "Hugo theme." }, - { "name": "Proxmox VE", "link": "https://www.proxmox.com/en/proxmox-virtual-environment/overview", "description": "Virtualization platform." }, - { "name": "regex101.com", "link": "https://regex101.com/", "description": "Regular expression tester." }, - { "name": "RFC 5646 language tags", "link": "https://www.rfc-editor.org/rfc/rfc5646.html", "description": "Language tag standard." }, - { "name": "ruff", "link": "https://docs.astral.sh/ruff/", "description": "Python linter and formatter." }, - { "name": "Serilog", "link": "https://serilog.net/", "description": "Structured logging library for .NET." }, - { "name": "Utf8JsonAsyncStreamReader", "link": "https://github.com/gragra33/Utf8JsonAsyncStreamReader", "description": "Streaming JSON reader for .NET." }, - { "name": "uv", "link": "https://docs.astral.sh/uv/", "description": "Python package and project manager." }, - { "name": "Whisper", "link": "https://github.com/Softcatala/whisper-ctranslate2", "description": "Speech recognition transcriber." }, - { "name": "Xml2CSharp", "link": "https://xmltocsharp.azurewebsites.net/", "description": "XML to C# class generator." }, - { "name": "xUnit.Net", "link": "https://xunit.net/", "description": "Test framework for .NET." } - ] -} +{ + "$schema": "./third-party-tools.schema.json", + "note": "The shared catalog behind a README's 3rd Party Tools section. A tool a repository uses is named, linked, and described the same way in every repository that uses it, so a reader moving between them recognizes it and a repository writing the section copies rather than invents. The description is written as a sentence, opening with a capital and closing with a full stop, and it says what the tool is, never what this repository does with it: a role written against local wiring is a second copy of a fact that lives in the workflow, it goes stale on its own schedule, and it reads as fleet divergence when the next repository wires the same tool differently. A tool used by one repository today is still declared here, because the point is that the second repository to adopt it finds the wording already written. This is a standard set rather than a complete one: a repository's tools are mostly its own, so a tool absent from this catalog is expected and the audit says nothing about it. What the audit checks is the intersection, meaning that a repository using a tool the catalog names matches the catalog's link and description for it. Extending the catalog is how a tool becomes shared, and it is a hub edit rather than a finding on a repository.", + "tools": [ + { "name": "7-Zip", "link": "https://www.7-zip.org/", "description": "File archiver." }, + { "name": "AwesomeAssertions", "link": "https://awesomeassertions.org/", "description": "Assertion library for .NET tests." }, + { "name": "Caddy", "link": "https://caddyserver.com", "description": "Web server." }, + { "name": "CliWrap", "link": "https://github.com/Tyrrrz/CliWrap", "description": "Process execution library for .NET." }, + { "name": "Create Pull Request", "link": "https://github.com/marketplace/actions/create-pull-request", "description": "GitHub action that opens a pull request from workspace changes." }, + { "name": "CSharpier", "link": "https://csharpier.com/", "description": "C# code formatter." }, + { "name": "cspell", "link": "https://cspell.org", "description": "Spell checker." }, + { "name": "Docker Hub Description", "link": "https://github.com/marketplace/actions/docker-hub-description", "description": "GitHub action that publishes a Docker Hub repository overview." }, + { "name": "Docker Run Action", "link": "https://github.com/marketplace/actions/docker-run-action", "description": "GitHub action that runs a step inside a container." }, + { "name": "dotnet-outdated", "link": "https://github.com/dotnet-outdated/dotnet-outdated", "description": "Outdated NuGet dependency reporter." }, + { "name": "editorconfig-checker", "link": "https://github.com/editorconfig-checker/editorconfig-checker", "description": "Line-ending and whitespace linter." }, + { "name": "ExifTool", "link": "https://exiftool.org/", "description": "Media metadata reader and writer." }, + { "name": "FFmpeg", "link": "https://www.ffmpeg.org/", "description": "Media transcoder." }, + { "name": "GH Release", "link": "https://github.com/marketplace/actions/gh-release", "description": "GitHub action that creates a release." }, + { "name": "Git Auto Commit", "link": "https://github.com/marketplace/actions/git-auto-commit", "description": "GitHub action that commits workspace changes." }, + { "name": "GitHub Actions", "link": "https://github.com/actions", "description": "CI and automation runner." }, + { "name": "GitHub Dependabot", "link": "https://github.com/dependabot", "description": "Dependency update bot." }, + { "name": "HandBrake", "link": "https://handbrake.fr/", "description": "Video transcoder." }, + { "name": "Hugo", "link": "https://gohugo.io", "description": "Static site generator." }, + { "name": "Husky.Net", "link": "https://alirezanet.github.io/Husky.Net/", "description": "Git hook manager for .NET." }, + { "name": "ISO 639-2 language tags", "link": "https://www.loc.gov/standards/iso639-2/langhome.html", "description": "Language code standard." }, + { "name": "ISO 639-3 language tags", "link": "https://iso639-3.sil.org/", "description": "Language code standard." }, + { "name": "JSON2CSharp", "link": "https://json2csharp.com", "description": "JSON to C# class generator." }, + { "name": "Markdown All in One", "link": "https://marketplace.visualstudio.com/items?itemName=yzhang.markdown-all-in-one", "description": "Markdown editing extension." }, + { "name": "markdownlint-cli2", "link": "https://github.com/DavidAnson/markdownlint-cli2", "description": "Markdown linter." }, + { "name": "MediaInfo", "link": "https://mediaarea.net/en-us/MediaInfo/", "description": "Media file inspector." }, + { "name": "MKVToolNix", "link": "https://mkvtoolnix.download/", "description": "Matroska container toolset." }, + { "name": "NEbml", "link": "https://github.com/OlegZee/NEbml", "description": "EBML reader and writer for .NET." }, + { "name": "Nerdbank.GitVersioning", "link": "https://github.com/dotnet/Nerdbank.GitVersioning", "description": "Version computation from git height." }, + { "name": "PaperMod", "link": "https://github.com/adityatelange/hugo-PaperMod", "description": "Hugo theme." }, + { "name": "Proxmox VE", "link": "https://www.proxmox.com/en/proxmox-virtual-environment/overview", "description": "Virtualization platform." }, + { "name": "regex101.com", "link": "https://regex101.com/", "description": "Regular expression tester." }, + { "name": "RFC 5646 language tags", "link": "https://www.rfc-editor.org/rfc/rfc5646.html", "description": "Language tag standard." }, + { "name": "ruff", "link": "https://docs.astral.sh/ruff/", "description": "Python linter and formatter." }, + { "name": "Serilog", "link": "https://serilog.net/", "description": "Structured logging library for .NET." }, + { "name": "Utf8JsonAsyncStreamReader", "link": "https://github.com/gragra33/Utf8JsonAsyncStreamReader", "description": "Streaming JSON reader for .NET." }, + { "name": "uv", "link": "https://docs.astral.sh/uv/", "description": "Python package and project manager." }, + { "name": "Whisper", "link": "https://github.com/Softcatala/whisper-ctranslate2", "description": "Speech recognition transcriber." }, + { "name": "Xml2CSharp", "link": "https://xmltocsharp.azurewebsites.net/", "description": "XML to C# class generator." }, + { "name": "xUnit.Net", "link": "https://xunit.net/", "description": "Test framework for .NET." } + ] +} diff --git a/spec/third-party-tools.schema.json b/spec/third-party-tools.schema.json index 23f72062..3876ffd3 100644 --- a/spec/third-party-tools.schema.json +++ b/spec/third-party-tools.schema.json @@ -1,26 +1,26 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/ptr727/ProjectTemplate/spec/third-party-tools.schema.json", - "title": "Shared 3rd party tool catalog", - "type": "object", - "required": ["tools"], - "additionalProperties": false, - "properties": { - "$schema": { "type": "string" }, - "note": { "type": "string" }, - "tools": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "required": ["name", "link", "description"], - "additionalProperties": false, - "properties": { - "name": { "type": "string", "minLength": 1, "description": "The tool's name as a README links it." }, - "link": { "type": "string", "minLength": 1, "description": "The one URL every repository links this tool by." }, - "description": { "type": "string", "minLength": 1, "description": "What the tool is, never what a repository does with it." } - } - } - } - } -} +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/ptr727/ProjectTemplate/spec/third-party-tools.schema.json", + "title": "Shared 3rd party tool catalog", + "type": "object", + "required": ["tools"], + "additionalProperties": false, + "properties": { + "$schema": { "type": "string" }, + "note": { "type": "string" }, + "tools": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["name", "link", "description"], + "additionalProperties": false, + "properties": { + "name": { "type": "string", "minLength": 1, "description": "The tool's name as a README links it." }, + "link": { "type": "string", "minLength": 1, "description": "The one URL every repository links this tool by." }, + "description": { "type": "string", "minLength": 1, "description": "What the tool is, never what a repository does with it." } + } + } + } + } +} diff --git a/spec/type-model.md b/spec/type-model.md index b1d09e62..40a610ec 100644 --- a/spec/type-model.md +++ b/spec/type-model.md @@ -1,79 +1,79 @@ -# Project Type Model - -Companion to [section-model.md][section-model] and [fidelity-model.md][fidelity-model]. Those define how carried *content* is verified. This one defines how a repo's **types** (what it is built from and for) are declared, validated, and checked. It is the ground truth an agent or human consults before adding a type, a profile, or a type check, not a judgment re-derived each session. - -**Rollout status.** This model is being wired in stages. Where a rule below names a check `audit.py` does not yet run, or a schema field not yet defined, this doc is the contract that implementation realizes. - -## Declaration is the source of truth - -A repo's types are **declared** in its [registry/repos.json][repos] entry (`types`), and a language type may also declare a **profile** (below). The audit runs the checks for each declared type plus the cross-cutting dimensions. Declaration, not inference, is authoritative: the registry states what the repo *is*, and [project-types.json][types] holds each type's requirements and checks. - -This mirrors the fleet principle that *the registry is ground truth about reality, not intent* (the `setup.driftnotes.current` check, over the registry `driftNotes` field): a declaration is a claim about the repo that must match what the repo actually contains. - -## Detection validates, it does not classify - -Each type in `project-types.json` carries `detect` patterns (files or markers that evidence the type). Detection is a **validator and a discovery aid**, never an auto-classifier. It checks declarations against reality and surfaces candidates, but it does not decide a repo's types on its own. The two axes give four cases: - -| | detected | not detected | -| --- | --- | --- | -| **declared** | consistent, so the checks run | **false declaration**, a finding (e.g. `cpp` declared, no C/C++ files present) | -| **not declared** | **discovery advisory**, so declare it or mark it ignored | nothing to do | - -The undeclared-but-detected advisory has three honest resolutions, all explicit intent, never silent: - -- **declare** it, so its checks run. -- **ignore** it with an explicit suppression carrying a reason (the driftNote shape), for a language deliberately not tracked (vendored third-party code, an incidental snippet). -- leave it as a standing advisory until decided. - -A false declaration is always a finding: a claim the repo does not back is drift, the same way a stale driftNote is. - -## Profiles: build vs lint-only - -A language type is present at one of two **depths**, declared as its `profile`: - -- **build** - the language is compiled, tested, and/or packaged in this repo. Its full check set applies (style, type-check, tests, coverage, packaging). -- **lint-only** - the language is present and style-checked here, but not built: there is no build/test/package for it in this repo. Only its lint/style/type-check checks apply. Build, test, coverage, and packaging checks are N/A. - -Each check may declare the **minimum profile** it needs via a `minProfile` field. A check without one applies at every profile, and a check with `minProfile: build` applies only at `build`. So lint/style/type-check checks omit it, while build/test/coverage/package checks set `build`. The audit uses the declared profile to hold the coverage requirement (the CODECOV_TOKEN secret and the codecov.yml file) as N/A for a lint-only language, replacing the older per-check "N/A for the SCRIPTS profile" prose. - -The profile is **declared and validated**, not merely detected. `python` already reads its shape structurally from `pyproject.toml` (a uv PROJECT with tests and a lockfile, versus stdlib SCRIPTS tooling). That structural read becomes the profile **validator**. A declared `python` profile that contradicts the pyproject shape is a false declaration. One concept (the declared profile), checked by detection, rather than two ways to classify. - -### Consequence for cross-cutting checks - -A cross-cutting check that presumes a built, tested language must respect the profile. In particular the coverage checks (the `CODECOV_TOKEN` secret and the `codecov.yml` file presence) are **profile-aware**: they are N/A for a language whose declared profile has no tests. A lint-only language must never manufacture a coverage finding. - -## Languages - -Language types carry the style and type-check requirements for their language, gated by profile. A language that is only ever linted in the fleet is `lint-only` by nature and defines no build/test/package checks: - -- **cpp** - C/C++ present for style only. The check of record is **clang-format** (a shared config driving the editor, the CLI, and CI, a `parity.lang` arm), feeding the operational lint CI. Deeper semantic and static analysis is intentionally out of scope here. For a codegen or config repo, the C++ is scaffolded and completed by its downstream toolchain (an ESPHome compile), which does the compilation-time checking, and clang-tidy would need a compile database the repo does not have. A repo's `.h` is read as C++ by context (Arduino/ESPHome), since the extension alone is ambiguous. - -## Generators - -A **generator** type is what a repo builds its deliverable *with*, where the deliverable is not code: a static-site generator, a documentation builder. It is named for the generator (`hugo`) rather than for the transport that ships the result, because what a repo builds and where the result lands are separate axes. The destination lives in the registry `publish[]` entry (`{ target, mechanism }`), so a repo changes transport without changing type, and a second transport is a new **mechanism** rather than a new type. Baking the transport into the type is what makes the set explode combinatorially: one generator over two transports would otherwise need two types. - -There is no `static-site` to `hugo` hierarchy while the type has one member. Instead each check's `assert` is phrased without naming the generator wherever the requirement generalizes (the URL-contract gate and its length floor, the rendered output never committed, the generator pinned by version and digest, vendored-dependency provenance), and names it only where a generator-specific construct *is* the letter, such as a build flag. When a second generator joins the fleet, promoting the generic checks to a shared type is then a registry edit rather than a rewrite, which is the property the phrasing rule exists to preserve. Paying for that abstraction at one member would be the more expensive mistake. - -A generator type declares no `profiles`. Build versus lint-only is a depth of *language* presence, so a profile on a generator type would assert nothing, and `spec/validate.py` rejects a declared profile whose type does not define one. - -## Changing the type set carries review weight - -The set of types, their profiles, and each type's checks is governed, like the section and fidelity models. - -- **Adding a type or a check** declares a new requirement for every repo that carries it. Add it to `project-types.json`, to the schema where the shape changes, and to this doc in the same change. -- **Declaring or changing a repo's type or profile** is a claim about the repo. Detection validates it, and a contradiction is a finding to reconcile, not a liberty. -- **A detected-but-undeclared language** is drift to resolve (declare or ignore-with-reason), not silently accepted. - -## Enforcement - -`registry/repos.json` declares each repo's types and profiles. [validate.py][validate] proves the declarations are well-formed against [project-types.schema.json][schema]. [audit.py][audit] runs each declared type's checks at its profile, validates declarations against detection (false declaration, discovery advisory, honored ignores), and holds profile-gated checks N/A off-profile. - -<!-- Internal --> - -[audit]: ./audit.py -[fidelity-model]: ./fidelity-model.md -[repos]: ../registry/repos.json -[schema]: ./project-types.schema.json -[section-model]: ./section-model.md -[types]: ./project-types.json -[validate]: ./validate.py +# Project Type Model + +Companion to [section-model.md][section-model] and [fidelity-model.md][fidelity-model]. Those define how carried *content* is verified. This one defines how a repo's **types** (what it is built from and for) are declared, validated, and checked. It is the ground truth an agent or human consults before adding a type, a profile, or a type check, not a judgment re-derived each session. + +**Rollout status.** This model is being wired in stages. Where a rule below names a check `audit.py` does not yet run, or a schema field not yet defined, this doc is the contract that implementation realizes. + +## Declaration is the source of truth + +A repo's types are **declared** in its [registry/repos.json][repos] entry (`types`), and a language type may also declare a **profile** (below). The audit runs the checks for each declared type plus the cross-cutting dimensions. Declaration, not inference, is authoritative: the registry states what the repo *is*, and [project-types.json][types] holds each type's requirements and checks. + +This mirrors the fleet principle that *the registry is ground truth about reality, not intent* (the `setup.driftnotes.current` check, over the registry `driftNotes` field): a declaration is a claim about the repo that must match what the repo actually contains. + +## Detection validates, it does not classify + +Each type in `project-types.json` carries `detect` patterns (files or markers that evidence the type). Detection is a **validator and a discovery aid**, never an auto-classifier. It checks declarations against reality and surfaces candidates, but it does not decide a repo's types on its own. The two axes give four cases: + +| | detected | not detected | +| --- | --- | --- | +| **declared** | consistent, so the checks run | **false declaration**, a finding (e.g. `cpp` declared, no C/C++ files present) | +| **not declared** | **discovery advisory**, so declare it or mark it ignored | nothing to do | + +The undeclared-but-detected advisory has three honest resolutions, all explicit intent, never silent: + +- **declare** it, so its checks run. +- **ignore** it with an explicit suppression carrying a reason (the driftNote shape), for a language deliberately not tracked (vendored third-party code, an incidental snippet). +- leave it as a standing advisory until decided. + +A false declaration is always a finding: a claim the repo does not back is drift, the same way a stale driftNote is. + +## Profiles: build vs lint-only + +A language type is present at one of two **depths**, declared as its `profile`: + +- **build** - the language is compiled, tested, and/or packaged in this repo. Its full check set applies (style, type-check, tests, coverage, packaging). +- **lint-only** - the language is present and style-checked here, but not built: there is no build/test/package for it in this repo. Only its lint/style/type-check checks apply. Build, test, coverage, and packaging checks are N/A. + +Each check may declare the **minimum profile** it needs via a `minProfile` field. A check without one applies at every profile, and a check with `minProfile: build` applies only at `build`. So lint/style/type-check checks omit it, while build/test/coverage/package checks set `build`. The audit uses the declared profile to hold the coverage requirement (the CODECOV_TOKEN secret and the codecov.yml file) as N/A for a lint-only language, replacing the older per-check "N/A for the SCRIPTS profile" prose. + +The profile is **declared and validated**, not merely detected. `python` already reads its shape structurally from `pyproject.toml` (a uv PROJECT with tests and a lockfile, versus stdlib SCRIPTS tooling). That structural read becomes the profile **validator**. A declared `python` profile that contradicts the pyproject shape is a false declaration. One concept (the declared profile), checked by detection, rather than two ways to classify. + +### Consequence for cross-cutting checks + +A cross-cutting check that presumes a built, tested language must respect the profile. In particular the coverage checks (the `CODECOV_TOKEN` secret and the `codecov.yml` file presence) are **profile-aware**: they are N/A for a language whose declared profile has no tests. A lint-only language must never manufacture a coverage finding. + +## Languages + +Language types carry the style and type-check requirements for their language, gated by profile. A language that is only ever linted in the fleet is `lint-only` by nature and defines no build/test/package checks: + +- **cpp** - C/C++ present for style only. The check of record is **clang-format** (a shared config driving the editor, the CLI, and CI, a `parity.lang` arm), feeding the operational lint CI. Deeper semantic and static analysis is intentionally out of scope here. For a codegen or config repo, the C++ is scaffolded and completed by its downstream toolchain (an ESPHome compile), which does the compilation-time checking, and clang-tidy would need a compile database the repo does not have. A repo's `.h` is read as C++ by context (Arduino/ESPHome), since the extension alone is ambiguous. + +## Generators + +A **generator** type is what a repo builds its deliverable *with*, where the deliverable is not code: a static-site generator, a documentation builder. It is named for the generator (`hugo`) rather than for the transport that ships the result, because what a repo builds and where the result lands are separate axes. The destination lives in the registry `publish[]` entry (`{ target, mechanism }`), so a repo changes transport without changing type, and a second transport is a new **mechanism** rather than a new type. Baking the transport into the type is what makes the set explode combinatorially: one generator over two transports would otherwise need two types. + +There is no `static-site` to `hugo` hierarchy while the type has one member. Instead each check's `assert` is phrased without naming the generator wherever the requirement generalizes (the URL-contract gate and its length floor, the rendered output never committed, the generator pinned by version and digest, vendored-dependency provenance), and names it only where a generator-specific construct *is* the letter, such as a build flag. When a second generator joins the fleet, promoting the generic checks to a shared type is then a registry edit rather than a rewrite, which is the property the phrasing rule exists to preserve. Paying for that abstraction at one member would be the more expensive mistake. + +A generator type declares no `profiles`. Build versus lint-only is a depth of *language* presence, so a profile on a generator type would assert nothing, and `spec/validate.py` rejects a declared profile whose type does not define one. + +## Changing the type set carries review weight + +The set of types, their profiles, and each type's checks is governed, like the section and fidelity models. + +- **Adding a type or a check** declares a new requirement for every repo that carries it. Add it to `project-types.json`, to the schema where the shape changes, and to this doc in the same change. +- **Declaring or changing a repo's type or profile** is a claim about the repo. Detection validates it, and a contradiction is a finding to reconcile, not a liberty. +- **A detected-but-undeclared language** is drift to resolve (declare or ignore-with-reason), not silently accepted. + +## Enforcement + +`registry/repos.json` declares each repo's types and profiles. [validate.py][validate] proves the declarations are well-formed against [project-types.schema.json][schema]. [audit.py][audit] runs each declared type's checks at its profile, validates declarations against detection (false declaration, discovery advisory, honored ignores), and holds profile-gated checks N/A off-profile. + +<!-- Internal --> + +[audit]: ./audit.py +[fidelity-model]: ./fidelity-model.md +[repos]: ../registry/repos.json +[schema]: ./project-types.schema.json +[section-model]: ./section-model.md +[types]: ./project-types.json +[validate]: ./validate.py diff --git a/spec/validate.py b/spec/validate.py index e9b46f40..55265f2f 100755 --- a/spec/validate.py +++ b/spec/validate.py @@ -398,7 +398,7 @@ def check_secret_set(label, entry, need_kind): eol = repo.get("lineEndings") if eol is not None and eol not in ("lf", "crlf"): errors.append(f"{name}: lineEndings '{eol}' invalid (expected lf or crlf)") - # An operational repo's endings follow the consuming app's platform, so they must be declared, where a release repo omits the field and takes the fleet CRLF default. + # An operational repo's endings follow the consuming app's platform, so they must be declared, where a release repo omits the field and takes the fleet LF default. # Resolve the effective model the way configure.sh does, from the repo, then the defaults, then release. # The requirement then holds even where a repo relies on an operational defaults.workflowModel rather than setting its own. effective_model = model or default_model or "release" diff --git a/spec/workflow_reuse.py b/spec/workflow_reuse.py index c60994a6..c88fd380 100755 --- a/spec/workflow_reuse.py +++ b/spec/workflow_reuse.py @@ -406,9 +406,9 @@ def main(): rows, unreadable, empty = measure(registry, canonical_texts(), hub_slug) if "--report" in argv: content = render_report(rows, unreadable, empty, hub_sha()) - # CRLF matches the fleet default, since reports/*.md is CRLF. + # LF matches the fleet default, since reports/*.md is LF. # Bytes are written so the local platform does not re-translate them. - (audit.ROOT / REPORT_PATH).write_bytes(content.replace("\n", "\r\n").encode("utf-8")) + (audit.ROOT / REPORT_PATH).write_bytes(content.encode("utf-8")) print(f"Wrote {REPORT_PATH} ({len(content.splitlines())} lines)") return 0 total = summarize(rows) diff --git a/version.json b/version.json index fc6486d8..7a7364a9 100644 --- a/version.json +++ b/version.json @@ -1,10 +1,10 @@ -{ - "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "2.0", - "publicReleaseRefSpec": [ - "^refs/heads/main$" - ], - "nugetPackageVersion": { - "semVer": 2 - } -} +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", + "version": "2.0", + "publicReleaseRefSpec": [ + "^refs/heads/main$" + ], + "nugetPackageVersion": { + "semVer": 2 + } +} From cca8eff463d3cbc28a7b774b5536de465e94df57 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen <ptr727@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:28:15 -0700 Subject: [PATCH 2/3] Add the Fleet LF Rollout Tracking Doc docs/eol-lf-rollout.md checks off ProjectTemplate (converted in the prior commit) and Vantage-Config (not applicable, stays CRLF per its Windows-native consuming app), and lists the remaining 20 fleet repos from registry/repos.json unchecked, each with the conversion procedure that applies to its workflowModel/lineEndings shape. Hub-only, not carried downstream, the same way docs/fleet-map.md is hub-only. Verified: markdownlint-cli2, cspell, prose_lint.py --diff all pass. --- docs/eol-lf-rollout.md | 84 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 docs/eol-lf-rollout.md diff --git a/docs/eol-lf-rollout.md b/docs/eol-lf-rollout.md new file mode 100644 index 00000000..619d1c31 --- /dev/null +++ b/docs/eol-lf-rollout.md @@ -0,0 +1,84 @@ +# Fleet LF Rollout + +Tracks the fleet-wide line-ending default flip (CRLF to LF) repo by repo. The policy itself lives +in [GOVERNANCE.md "Line Endings"][governance-line-endings] and the `comment-and-doc-style` Skill's +[`references/line-endings.md`][line-endings]. This doc is the rollout checklist only, not a +restatement of the rule. It is **hub-only** and is not carried downstream, the same way +[`docs/fleet-map.md`][fleet-map] is hub-only, because it tracks the hub's own migration rather +than a fact a downstream repo's own docs need to carry. + +**Maintenance rule.** Check a repo's box in the same pull request that converts it, and update its +row if the conversion found something the summary below didn't anticipate (a file that genuinely +needs to stay CRLF beyond the ones already named). A register showing a repo unchecked after its +conversion PR merged is itself stale prose, so this doc is only trustworthy while that rule holds. + +## What Changed + +The fleet default flips from `[*] end_of_line = crlf` to `[*] end_of_line = lf` in +`.editorconfig`. The only CRLF exception going forward is `*.bat` / `*.cmd`, the one type Windows +itself requires it for. `.gitattributes` keeps its existing execution-sensitive LF pins (`*.sh`, +Dockerfiles, `uv.lock`, the shebang-executed `.py` by-path list) unchanged, now redundant with the +new default but retained as git-level enforcement independent of the editor. The hub +(`ProjectTemplate`) carried this change, including a one-time renormalization of every tracked +file the new default touches, in the pull request that added this doc. + +## Per-Repo Conversion + +For a `release` repo, or an operational repo whose `lineEndings` is already `lf`: pull the hub's +new `.editorconfig`, confirm `.gitattributes` needs no change (it doesn't, per the policy above), +renormalize every tracked file the new default now covers from CRLF to LF (skip anything with +a genuine reason to stay CRLF, there is none known outside `Vantage-Config`, see below), run +`editorconfig-checker` clean, and open the PR through the repo's normal branching model +([`operational-vs-release-workflow`][operational-vs-release-workflow] Skill). Isolate the +renormalization from any content edit in its own commit, verified with +`git diff --ignore-cr-at-eol`, per [`references/line-endings.md`][line-endings] "Editing +discipline". After merge, check the box below and reconcile the repo's `registry/repos.json` +entry per [GOVERNANCE.md "Repository Onboarding and Conformance"][governance-onboarding] if the +conversion surfaced anything the registry didn't already record. + +For an operational repo whose `lineEndings` is `crlf` (only `Vantage-Config` today): no +conversion. Its global default follows its consuming Windows-native app, not the fleet default, +per [`references/line-endings.md`][line-endings] "Operational (config) repos". Its box below is +checked as **not applicable**, not as converted. + +## Rollout Checklist + +Repos and their current `registry/repos.json` `workflowModel` / `lineEndings`, from the hub's own +registry as of this doc's authorship. + +- [x] **ProjectTemplate** (`release`): hub, converted in the pull request that added this doc +- [ ] **Utilities** (`release`) +- [ ] **LanguageTags** (`release`) +- [ ] **aiopurpleair** (`release`) +- [ ] **homeassistant-purpleair** (`release`) +- [ ] **Financial-Modeling** (`release`) +- [ ] **PlexCleaner** (`release`) +- [ ] **ESPHome-NonRoot** (`release`) +- [ ] **VSCode-Server-DotNetCore** (`release`) +- [ ] **NxWitness** (`release`) +- [ ] **HomeAutomation-Config** (`operational`, `lineEndings: lf`): already on the new default's + value. Verify rather than convert, since its own `.editorconfig`/`.gitattributes` may still + carry the old redundant per-type LF pins the hub dropped. +- [ ] **KiCadLibrary** (`release`) +- [ ] **EspDinIoT** (`release`) +- [ ] **ESPHome-Config** (`operational`, `lineEndings: lf`): verify, same reasoning as + HomeAutomation-Config +- [ ] **HomeAssistant-Config** (`operational`, `lineEndings: lf`): verify, same reasoning as + HomeAutomation-Config +- [ ] **DevKitCIoT** (`release`) +- [ ] **PhotoCleaner** (`release`) +- [ ] **MediaTools** (`release`) +- [ ] **AudioCleaner** (`release`) +- [x] **Vantage-Config** (`operational`, `lineEndings: crlf`): not applicable, stays CRLF +- [ ] **HolidayLights** (`release`) +- [ ] **Blog** (`release`, `lineEndings: lf`): already on the new default's value, a documented + release-repo exception before this rollout, per its `registry/repos.json` `driftNotes`. + Verify rather than convert, same reasoning as the three operational `lf` repos. + +<!-- Repo --> + +[fleet-map]: ./fleet-map.md +[governance-line-endings]: ../GOVERNANCE.md#line-endings +[governance-onboarding]: ../GOVERNANCE.md#repository-onboarding-and-conformance +[line-endings]: ../.agents/skills/comment-and-doc-style/references/line-endings.md +[operational-vs-release-workflow]: ../.agents/skills/operational-vs-release-workflow/SKILL.md From 963613f51ef3d6ddb79e6aa76eb753682e942183 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen <ptr727@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:39:07 -0700 Subject: [PATCH 3/3] Fix Stale CRLF-Default Prose in .gitattributes Comments Copilot review on PR #758 caught this: the pins themselves are untouched (per the maintainer's direction that .gitattributes stays as-is), but two comments still described the old CRLF default as current. Both now describe the new LF default plus the operational-repo CRLF override that can still trigger the pin's rationale. --- .gitattributes | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitattributes b/.gitattributes index ea97e4b3..a006f4cb 100644 --- a/.gitattributes +++ b/.gitattributes @@ -16,8 +16,8 @@ catalog/snippets/husky/pre-commit text eol=lf # This repository's own hook, which is the extensionless case the comment above names. .husky/pre-commit text eol=lf -# Vanilla `.py` follows the CRLF default, since Python's universal newlines accept CRLF and it is commonly edited on Windows. -# Pin LF only for a `.py` executed directly via its shebang, by path. +# Vanilla `.py` follows the `[*]` default (LF, or CRLF from an operational repo's Windows-native override), since Python's universal newlines accept either. +# Pin LF only for a `.py` executed directly via its shebang, by path, so it still runs on a Windows-native operational repo. # Those are the CI validation entry point, the fleet-audit runner, the agent-safety hook and its installer with the installer's tests, and the repo lint and review scripts with their tests. # Do not re-add a blanket `*.py text eol=lf`. spec/validate.py text eol=lf @@ -42,7 +42,7 @@ scripts/skills_install.py text eol=lf scripts/tests/test_skills_install.py text eol=lf # The uv tool regenerates uv.lock with LF on every platform, so pin it here. -# That makes git enforce LF on checkout and renormalize, so a CRLF-default repo does not fight the tool on every `uv lock` or `uv sync`. +# That makes git enforce LF on checkout and renormalize, so a CRLF-default operational repo does not fight the tool on every `uv lock` or `uv sync`. # A repo with no lockfile is unaffected, so this pin is forward-declared. uv.lock text eol=lf