diff --git a/AGENTS.md b/AGENTS.md index c76e8b94..13d20700 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Instructions for AI Coding Agents -**ProjectTemplate** is a governance, agent-orchestration, and workflow-audit repo for a fleet of related projects. 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. +**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 two things: 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 and Python), and the CI/CD workflow contract in [`WORKFLOW.md`](./WORKFLOW.md). diff --git a/CODESTYLE.md b/CODESTYLE.md index 4a6420b2..fd8642d8 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -2,7 +2,7 @@ This is the single code-style guide for the fleet. The **General** section applies to every language. Each **language section** (.NET, Python) 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) live in [GOVERNANCE.md][governance] and are not repeated here. +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 @@ -16,7 +16,7 @@ Use each tool's official casing in task labels, docs, and prose: `.NET` (not `.N 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.** The relevant language's clean-compile must pass before you commit, and CI runs the same checks as a backstop. +- **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. @@ -490,8 +490,10 @@ Before pushing or opening a PR: [analyzer-diagnostics-and-suppressions]: #analyzer-diagnostics-and-suppressions [clean-compile-verification]: #clean-compile-verification -[history]: ./HISTORY.md [governance]: ./GOVERNANCE.md +[governance-running-the-linters-locally]: ./GOVERNANCE.md#running-the-linters-locally-known-working-invocations +[governance-verification-discipline]: ./GOVERNANCE.md#verification-discipline +[history]: ./HISTORY.md [line-endings]: ./GOVERNANCE.md#line-endings [markdown-and-spelling]: #markdown-and-spelling [markdownlint-cli2]: ./.markdownlint-cli2.jsonc diff --git a/GOVERNANCE.md b/GOVERNANCE.md index af1a44b3..980741df 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -22,7 +22,7 @@ The specific rules in this file implement a few governing principles. Read these 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. Reads are unrestricted. The bounds below are on writes. -- **Write only to the current project's own repository.** Every state-changing call targets this project's `origin` and nothing else. A broad or logged-in identity is capability, not permission: a token that *can* reach another repository does not authorize writing to it. Writing to any other repository needs explicit, per-session human permission for that specific repository, and a "harmless test" write is still a write, so there is no probe exception. Reads from anywhere are fine. +- **Write only within the owner of the current project's repository.** Every state-changing call targets this project's `origin` or another repository under the same owner, which is the fleet the maintainer already administers. A broad or logged-in identity is capability, not permission: a token that *can* reach another owner's repository does not authorize writing to it. Writing under a **different owner** needs explicit human permission naming that repository, granted deliberately rather than assumed from a token's reach, 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 (`repo-config/configure.sh` does exactly this). 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. diff --git a/HISTORY.md b/HISTORY.md index 0645c803..e6bfe7fa 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,6 +1,6 @@ # ProjectTemplate -Governance, agent-orchestration, and workflow-audit hub for a fleet of related repositories. +Agent enablement for a fleet of repositories: autonomy and repeatable quality inside guardrails. ## Release History diff --git a/README.md b/README.md index 365fa65a..ffd9c874 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ProjectTemplate -Governance, agent-orchestration, and workflow-audit hub for a fleet of related repositories. +Agent enablement for a fleet of repositories: autonomy and repeatable quality inside guardrails. ## Build and Distribution @@ -24,13 +24,14 @@ Governance, agent-orchestration, and workflow-audit hub for a fleet of related r **Summary**: -- Governance, agent-orchestration, and workflow-audit hub for the fleet: shared rules, a machine-readable spec, a fleet registry, per-repo audit reports, and an audit-agent instruction set. Ships no application code. +- 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. ## Table of Contents - [What This Repo Is](#what-this-repo-is) +- [What It Achieves](#what-it-achieves) - [How This Repo Operates](#how-this-repo-operates) - [Rules](#rules) - [Always](#always) @@ -42,12 +43,17 @@ See [Release History][history] for the full history. - [For a README or Human-Facing Doc](#for-a-readme-or-human-facing-doc) - [For Workflows](#for-workflows) - [Development Environment Setup](#development-environment-setup) -- [TODO](#todo) - [License](#license) ## What This Repo Is -This repo is the single home for the shared rules the fleet follows, a machine-readable spec those rules 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). +**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. @@ -60,6 +66,27 @@ This repo is the single home for the shared rules the fleet follows, a machine-r - **[catalog/][catalog]** - reusable reference snippets (workflow tasks, config exemplars, devcontainers) the audit compares implementations against. - **[reports/][reports]** - per-repo audit output. +## 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]). @@ -128,21 +155,6 @@ A human-readable index of the rules agents enforce, implement, and audit. The au 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]). -## TODO - -Running backlog (kept here, in a committed file, so the guidance survives across environments where agent memory does not). - -- Run the first per-repo audits and populate [reports/][reports] for the seven cataloged repos. -- Classify the standardization-backlog repos in [registry/repos.json][repos] (marked `classificationPending`) on first audit. -- Canonicalize Python linter-config placement on `pyproject.toml` (one cataloged repo uses standalone `.ruff.toml` + `pyrightconfig.json`); track as a drift finding, fix downstream. -- Consider renaming this repo to reflect the audit-catalog identity (updates badge and link URLs across the fleet). -- Adopt the OCI annotation keys (`org.opencontainers.image.*`) for Docker image metadata across the Docker repos, replacing the ad-hoc and `org.label-schema.*` labels (from #363). -- Sweep `ManagePackageVersionsCentrally` placement to `Directory.Packages.props` fleet-wide (PlexCleaner sets it in `Directory.Build.props`, off the CODESTYLE canonical). -- Finish onboarding hardening (from #310): make the `AUDIT.md` audit a required onboarding step and run the per-type cold-start self-tests tracked in `reports/conformance-matrix.md` (`STANDUP.md` is already in place). -- Refresh the README (it has gone stale) and evaluate a lower-maintenance structure - for example a per-section index that points into each doc with a one-line description, keeping the README as the adoption and audit-instruction entry point with pointers to the other docs. A per-section index trades brevity for a sync obligation: it must track what the docs contain. -- Add a linter-only Python project type for codegen/boilerplate Python - code that runs during another tool's build to emit generated source (e.g. ESPHome codegen that produces enriched C++ at compile time), so it ships no unit tests and no coverage and needs only the linter. Keep it distinct from the existing `python` type, which is utility code that can and should carry unit tests and coverage (as in PlexCleaner). Until it exists, ESPHome-Config stays `source-only` and its `+python` reclassification is deferred - accept its one outstanding validation finding meanwhile. -- Add a fleet-standard clang-format config for the `cpp` type: a catalog snippet plus a CODESTYLE C++ section defining the style, the C++ analogue of the shared ruff config, so the `cpp` clang-format check references one canonical style rather than each repo inventing its own. Base it on the ESPHome-Config agent's proposed `.clang-format`. - ## License See [LICENSE][license]. @@ -158,17 +170,20 @@ See [LICENSE][license]. [agents]: ./AGENTS.md -[governance]: ./GOVERNANCE.md -[governance-branching-model]: ./GOVERNANCE.md#branching-model -[governance-pr-review-etiquette]: ./GOVERNANCE.md#pr-review-etiquette -[governance-running-the-linters-locally-known-working-invocations]: ./GOVERNANCE.md#running-the-linters-locally-known-working-invocations [audit]: ./AUDIT.md [catalog]: ./catalog/ [codestyle]: ./CODESTYLE.md [devcontainer]: ./docs/devcontainer.md +[files]: ./spec/files.json +[governance]: ./GOVERNANCE.md +[governance-branching-model]: ./GOVERNANCE.md#branching-model +[governance-pr-review-etiquette]: ./GOVERNANCE.md#pr-review-etiquette +[governance-running-the-linters-locally-known-working-invocations]: ./GOVERNANCE.md#running-the-linters-locally-known-working-invocations [history]: ./HISTORY.md [host-setup]: ./docs/host-setup.md [license]: ./LICENSE +[matrix]: ./reports/conformance-matrix.md +[project-types]: ./spec/project-types.json [readme-structure]: ./spec/readme-structure.md [repo-config]: ./repo-config/ [reports]: ./reports/ diff --git a/STANDUP.md b/STANDUP.md index 5602d1fa..a5b57c10 100644 --- a/STANDUP.md +++ b/STANDUP.md @@ -34,6 +34,19 @@ git config --local --get user.email || true # expect no output 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. +## 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. + ## 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. @@ -53,17 +66,33 @@ Then **read** `CODESTYLE.md` and the `GOVERNANCE.md` documentation-style rules, **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`, 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. -**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. Three topical docs take it, chosen by what the content **is**: +**`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. + +**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 an operational repo is run, covering runbooks, backup, log and debug procedures, tool-usage notes, and config layout. +- `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 an `operational` repo**, not optional, so it appears in the baseline above with `appliesTo: ["operational"]`. 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. It is the operational-repo 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` stays advisory and is not required by any selector. +**`OPERATIONS.md` is required on an `operational` repo**, not optional, so it appears in the baseline above with `appliesTo: ["operational"]`. 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. It is the operational-repo 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. @@ -73,7 +102,24 @@ Implement the Actions that satisfy [`WORKFLOW.md`][workflow] for the repo's type ## 4. Apply Settings, Rulesets, and Secrets -Run `repo-config/configure.sh apply [owner/repo] [release|operational]` (the repo defaults to the current one, the model to the registry lookup or, absent a registry, to the carried payload) 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. 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. +**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 "/" --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]` (the repo defaults to the current one, the model to the registry lookup or, absent a registry, to the carried payload) 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. 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 @@ -106,14 +152,18 @@ The same [`AUDIT.md`][audit] run is the on-demand audit for any known repo, and [agents]: ./AGENTS.md [audit]: ./AUDIT.md [codestyle]: ./CODESTYLE.md +[content-import]: ./docs/content-import.md [files]: ./spec/files.json [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-setup]: ./docs/host-setup.md [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-readme]: ./repo-config/README.md [repos]: ./registry/repos.json [scope-model]: ./spec/scope-model.md [secrets]: ./spec/secrets.json diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..43e5b4ad --- /dev/null +++ b/TODO.md @@ -0,0 +1,21 @@ +# TODO + +Running backlog for this repo, kept in a committed file so the guidance survives across environments where agent memory does not. + +- Populate [reports/][reports] for the cataloged repos that still have no audit, since a registry `status` of `cataloged` asserts a result that only a committed report evidences. Eight repos have one. +- Canonicalize Python linter-config placement on `pyproject.toml` (one cataloged repo uses standalone `.ruff.toml` + `pyrightconfig.json`), track as a drift finding, fix downstream. +- Consider renaming this repo to reflect the audit-catalog identity (updates badge and link URLs across the fleet). +- Adopt the OCI annotation keys (`org.opencontainers.image.*`) for Docker image metadata across the Docker repos, replacing the ad-hoc and `org.label-schema.*` labels (from #363). +- Sweep `ManagePackageVersionsCentrally` placement to `Directory.Packages.props` fleet-wide (PlexCleaner sets it in `Directory.Build.props`, off the CODESTYLE canonical). +- Finish onboarding hardening (from #310): make the `AUDIT.md` audit a required onboarding step and run the per-type cold-start self-tests tracked in [reports/conformance-matrix.md][matrix] (`STANDUP.md` is already in place). +- Refresh the README (it has gone stale) and evaluate a lower-maintenance structure, for example a per-section index that points into each doc with a one-line description, keeping the README as the adoption and audit-instruction entry point with pointers to the other docs. A per-section index trades brevity for a sync obligation: it must track what the docs contain. +- Add a linter-only Python project type for codegen/boilerplate Python, code that runs during another tool's build to emit generated source (e.g. ESPHome codegen that produces enriched C++ at compile time), so it ships no unit tests and no coverage and needs only the linter. Keep it distinct from the existing `python` type, which is utility code that can and should carry unit tests and coverage (as in PlexCleaner). Until it exists, ESPHome-Config stays `source-only` and its `+python` reclassification is deferred, so accept its one outstanding validation finding meanwhile. +- Add a fleet-standard clang-format config for the `cpp` type: a catalog snippet plus a CODESTYLE C++ section defining the style, the C++ analogue of the shared ruff config, so the `cpp` clang-format check references one canonical style rather than each repo inventing its own. Base it on the ESPHome-Config agent's proposed `.clang-format`. +- Sweep the 13 `dash` and `semicolon` findings in `README.md`. They are deferred rather than dropped, because two changes to that file are in flight and a third overlapping edit would conflict with both for no gain. +- Clean the comment shape in `.editorconfig`, `.gitattributes` and `.gitignore` (44 `comment-wrap` and `comment-case` findings). These are the first files every new repo copies, so until they are fixed a new repo learns the shape the rules forbid. +- Sweep the 54 `comment-wrap` and `comment-case` findings in `repo-config/configure.sh`. It is carried `verbatim`, so a downstream copy is byte-matched and cannot fix them locally, which makes this the hub's whole-class sweep rather than a next-edit correction. + + + +[matrix]: ./reports/conformance-matrix.md +[reports]: ./reports/ diff --git a/docs/content-import.md b/docs/content-import.md new file mode 100644 index 00000000..13ee2d0d --- /dev/null +++ b/docs/content-import.md @@ -0,0 +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. + + + +[governance-verification-discipline]: ../GOVERNANCE.md#verification-discipline +[standup]: ../STANDUP.md diff --git a/host-setup/agent-safety/README.md b/host-setup/agent-safety/README.md index b9c25318..8cb5205c 100644 --- a/host-setup/agent-safety/README.md +++ b/host-setup/agent-safety/README.md @@ -6,7 +6,7 @@ Per-machine, user-account-scoped guards against an agent making a mis-targeted G 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 outside the checkout's `origin`. 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. +- **`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. - **A `## GitHub Write Safety (Any Project, Every Session)` section in `CLAUDE.md`**: the same three rules as behavioral guidance, loaded into every session on the machine (including ad-hoc work outside any project). It mirrors the committed `GOVERNANCE.md` "Repository Boundaries and Write Safety" rules, which only reach fleet repos. 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. diff --git a/host-setup/agent-safety/claude-md-safety.md b/host-setup/agent-safety/claude-md-safety.md index 915c9fb6..8908294f 100644 --- a/host-setup/agent-safety/claude-md-safety.md +++ b/host-setup/agent-safety/claude-md-safety.md @@ -3,7 +3,7 @@ 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 - outward-facing and hard to reverse. 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. 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 to the current project's own repository.** Every state-changing call targets this checkout's `origin` and nothing else. A broad or logged-in identity is capability, not permission. Another repository needs explicit, per-session human permission for that specific repository, and a "harmless test" write is still a write. +- **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 - it may have succeeded on the server. diff --git a/host-setup/agent-safety/gh-write-guard.py b/host-setup/agent-safety/gh-write-guard.py index 47bc3bc0..e5f83a50 100644 --- a/host-setup/agent-safety/gh-write-guard.py +++ b/host-setup/agent-safety/gh-write-guard.py @@ -14,7 +14,10 @@ 1. a state-changing gh call whose output is discarded or forced to success (>/dev/null, 2>/dev/null, &>/dev/null, || true, || :, || echo) 2. a GraphQL mutation passing a literal GitHub node id (PRRT_/PR_/BOT_/...) instead of a $variable - 3. a gh write with an explicit -R/--repo/repos// target outside the checkout's origin + 3. a gh write with an explicit -R/--repo/repos// target under an owner other than the + checkout origin's, unless the maintainer granted that target in GH_WRITE_GUARD_ALLOW. Sibling + repositories under the same owner are allowed, since the harm this guards is reaching a stranger's + repository, not working across your own fleet in one session. 4. a git operation that would only land by bypassing an active branch rule: a direct push to a branch whose rules require a pull request, a force-push where history is protected, a delete where deletion is blocked, or an explicit-bypass flag (`gh pr merge --admin`, `git commit/push --no-verify`). The @@ -83,7 +86,15 @@ _NODE_ID_LITERAL = re.compile(r'^(?:[A-Z]{1,5}_[A-Za-z0-9_\-]{12,}|MD[A-Za-z0-9]{12,})$') # -F/-f name=VALUE, capturing the value - handles "quoted" and bare _FIELD_ASSIGN = re.compile(r"""(?:-F|-f|--field|--raw-field)\s+[A-Za-z_][\w]*=(?P'[^']*'|"[^"]*"|\S+)""") -_EXPLICIT_REPO = re.compile(r"(?:-R|--repo)\s+(?P['\"]?)(?P[^\s'\"]+)(?P=q)") +# Every spelling gh accepts for the target flag: `--repo x`, `--repo=x`, `-R x`, `-R=x`, and the attached +# short form `-Rx`. A form left out is not a near-miss, it is a silent bypass of the whole repository +# scope, so the separator is matched rather than assumed to be a space. The look-behind requires the flag +# to start a shell token (whitespace before it, or the string start), which is where a real flag always +# sits, so a value that opens a quoted span (`--title "-Rowner/repo"`) is not read as a target. +# A mention inside prose (`--title "use -Rowner/repo"`) IS still read as a target and still denies. +# A space precedes it exactly as one precedes a real flag, so no look-behind can separate the two. +# Telling a flag from text needs argv-position parsing, the way _push_targets does it for git push. +_EXPLICIT_REPO = re.compile(r"(?['\"]?)(?P[^\s'\"]+)(?P=q)") _API_REPO_PATH = re.compile(r"\bgh\s+api\b[^\n|]*?\brepos/(?P[A-Za-z0-9_.\-]+)/(?P[A-Za-z0-9_.\-]+)") @@ -112,6 +123,38 @@ def _origin_owner_repo(cwd): return (m.group(1).lower(), m.group(2).lower()) if m else None +_ALLOW_ENV = "GH_WRITE_GUARD_ALLOW" + + +def _granted_targets(environ=None): + """Maintainer-granted write targets, as {(owner, repo)} with repo '*' meaning any repo of that owner. + + Read from the environment the agent's session was launched with, which is the one channel the agent + cannot set for itself: a hook runs as its own process, so an inline `VAR=x cmd` prefix or an `export` + in a Bash call never reaches here. Granting is therefore a deliberate maintainer act taken outside the + session, not something an agent can do to get past a block it just hit. + """ + out = set() + raw = (environ if environ is not None else os.environ).get(_ALLOW_ENV, "") + for tok in re.split(r"[,\s]+", raw): + if "/" not in tok: + continue + owner, repo = tok.split("/", 1) + owner, repo = owner.strip().lower(), repo.strip().lower() + if owner and repo: + out.add((owner, repo)) + return out + + +def _target_permitted(target, origin, granted): + """True when a write to target is in scope for a checkout whose origin is origin.""" + # Same owner covers the origin itself and every sibling repository, which is the case the maintainer + # works in daily. A different owner is the incident shape and needs the grant. + if target[0] == origin[0]: + return True + return target in granted or (target[0], "*") in granted + + def _live_branch_rules(owner, repo, branch): """Return the set of active rule types on a branch, or None if the query cannot be resolved. @@ -381,13 +424,14 @@ def _check_push_bypass(cmd, cwd, origin, current_branch=None, rules_lookup=None) return "allow", "" -def classify(cmd, cwd=None, origin=None, current_branch=None, rules_lookup=None): +def classify(cmd, cwd=None, origin=None, current_branch=None, rules_lookup=None, environ=None): """Return (decision, reason). decision is 'allow' or 'deny'. origin, when given, is a (owner, repo) tuple used instead of resolving from cwd - the self-test - passes it for a deterministic, offline run. current_branch and rules_lookup are likewise test seams: - current_branch stands in for the git resolution of a bare push, and rules_lookup(branch) stands in - for the live branch-rules query. + passes it for a deterministic, offline run. current_branch, rules_lookup and environ are likewise + test seams: current_branch stands in for the git resolution of a bare push, rules_lookup(branch) + stands in for the live branch-rules query, and environ stands in for the process environment the + maintainer's grant is read from. """ # Fold shell line-continuations so a multi-line Bash invocation (`gh pr merge 5 \ --admin`) # parses as one command; only backslash-newline is joined, so a real newline between commands still @@ -434,14 +478,17 @@ def classify(cmd, cwd=None, origin=None, current_branch=None, rules_lookup=None) "'Repository Boundaries and Write Safety'." ) - # 3. explicit target outside origin + # 3. explicit target outside the origin's owner if origin is None: origin = _origin_owner_repo(cwd) targets = [] - mr = _EXPLICIT_REPO.search(cmd) - if mr and "/" in mr.group("r") and "<" not in mr.group("r"): - o, r = mr.group("r").split("/", 1) - targets.append((o.lower(), r.lower())) + # Every occurrence, not the first: a compound command carries one target per invocation, and reading + # only the first checks the harmless one while the write after `&&` goes unexamined. + for mr in _EXPLICIT_REPO.finditer(cmd): + val = mr.group("r") + if "/" in val and "<" not in val: + o, r = val.split("/", 1) + targets.append((o.lower(), r.lower())) for m in _API_REPO_PATH.finditer(cmd): if "<" not in m.group("owner"): targets.append((m.group("owner").lower(), m.group("repo").lower())) @@ -449,13 +496,19 @@ def classify(cmd, cwd=None, origin=None, current_branch=None, rules_lookup=None) # compare an explicit target against, so this check is skipped and rules 1-2 still apply. A node-id # target is invisible here regardless - that is what rule 2 guards. if origin: + granted = _granted_targets(environ) for t in targets: - if t != origin: + if not _target_permitted(t, origin, granted): return "deny", ( - f"This write targets {t[0]}/{t[1]}, which is not this checkout's origin " - f"({origin[0]}/{origin[1]}). Write only to the current project's own repository. " - "Another repository needs explicit per-session permission. See GOVERNANCE.md " - "'Repository Boundaries and Write Safety'." + f"This write targets {t[0]}/{t[1]}, under a different owner than this checkout's " + f"origin ({origin[0]}/{origin[1]}). Writes reach the origin and its sibling " + f"repositories under {origin[0]}, and a different owner is the shape that caused a " + f"stray comment on a stranger's repository. Ask the maintainer to grant it in " + f"{_ALLOW_ENV} (\"{t[0]}/{t[1]}\", or \"{t[0]}/*\" for that whole owner) before the " + "session starts, and do not set it yourself, since a permission the agent grants " + "itself is not a permission. See GOVERNANCE.md 'Repository Boundaries and Write " + "Safety', or the same section of the user-level CLAUDE.md where the repo has no " + "GOVERNANCE.md." ) return "allow", "" @@ -488,6 +541,29 @@ def classify(cmd, cwd=None, origin=None, current_branch=None, rules_lookup=None) ("gh api graphql -f query='mutation{resolveReviewThread(input:{threadId:$t}){thread{isResolved}}}' -F t=\"TODO_fixit\"", "allow", "short all-caps token is not a node id"), ] +# Rule-3 (repository scope) cases. Each carries the environment the grant is read from, so the run never +# depends on the environment the self-test happens to inherit. Origin is ptr727/plexcleaner throughout. +_SCOPE_CASES = [ + # (command, environ, expected_decision, label) + ("gh issue create --repo ptr727/PhotoCleaner --title x --body y", {}, "allow", "sibling repo under the same owner"), + ("gh api repos/ptr727/PhotoCleaner/issues -f title=x", {}, "allow", "sibling repo via an explicit API path"), + ("gh issue create --repo esphome/esphome --title x --body y", {}, "deny", "different owner with no grant"), + ("gh issue create --repo esphome/esphome --title x --body y", {_ALLOW_ENV: "esphome/esphome"}, "allow", "different owner named in the grant"), + ("gh issue create --repo esphome/esphome --title x --body y", {_ALLOW_ENV: "esphome/*"}, "allow", "different owner granted by owner wildcard"), + ("gh issue create --repo esphome/aioesphomeapi --title x", {_ALLOW_ENV: "esphome/esphome"}, "deny", "a repo grant does not extend to that owner's other repos"), + ("gh issue comment 5 -R mankatcheung/job-finder --body hi", {_ALLOW_ENV: "esphome/*"}, "deny", "the incident: a grant for one owner does not reach another"), + ("gh issue create --repo esphome/esphome --title x", {_ALLOW_ENV: "not-an-owner-repo"}, "deny", "a malformed grant grants nothing"), + ("GH_WRITE_GUARD_ALLOW=esphome/esphome gh issue create --repo esphome/esphome --title x", {}, "deny", "an inline env prefix is part of the command, not the hook's environment"), + # Every spelling of the target flag. A form the extraction misses is a silent bypass of rule 3, not a + # near-miss, so each is asserted against a foreign owner that must deny. + ("gh issue create --repo=esphome/esphome --title x", {}, "deny", "--repo=value equals form"), + ("gh issue create -R=esphome/esphome --title x", {}, "deny", "-R=value equals form"), + ("gh issue create -Resphome/esphome --title x", {}, "deny", "-Rvalue attached short form"), + ("gh issue create --repo ptr727/PhotoCleaner --title x && gh issue create --repo esphome/esphome --title y", {}, "deny", "a foreign target in the second invocation of a compound is read"), + ("gh issue create --repo=ptr727/PhotoCleaner --title x", {}, "allow", "equals form to a sibling owner still allows"), + ("gh issue create --repo ptr727/PhotoCleaner --title \"-Resphome/esphome\"", {}, "allow", "a value opening a quoted span is not a flag"), +] + # Rule-4 (branch-rule bypass) cases. Each carries its own branch->rules map so the run is deterministic # and offline - the real hook queries the live rules, here rules_lookup is injected. current_branch # stands in for the git resolution of a bare push. `None` rules mean the query could not be read. @@ -555,13 +631,22 @@ def _selftest(): origin = ("ptr727", "plexcleaner") ok = True for cmd, want, label in _CASES: - got, _ = classify(cmd, origin=origin, current_branch="feature/x", rules_lookup=lambda br: set()) + got, _ = classify(cmd, origin=origin, current_branch="feature/x", rules_lookup=lambda br: set(), + environ={}) + mark = "ok " if got == want else "FAIL" + if got != want: + ok = False + print(f" {mark} [{got:5}] want={want:5} {label}") + for cmd, env, want, label in _SCOPE_CASES: + got, _ = classify(cmd, origin=origin, current_branch="feature/x", rules_lookup=lambda br: set(), + environ=env) mark = "ok " if got == want else "FAIL" if got != want: ok = False print(f" {mark} [{got:5}] want={want:5} {label}") for cmd, cur, rmap, want, label in _GIT_CASES: - got, _ = classify(cmd, origin=origin, current_branch=cur, rules_lookup=lambda br, _m=rmap: _m.get(br)) + got, _ = classify(cmd, origin=origin, current_branch=cur, rules_lookup=lambda br, _m=rmap: _m.get(br), + environ={}) mark = "ok " if got == want else "FAIL" if got != want: ok = False diff --git a/spec/readme-structure.md b/spec/readme-structure.md index a96f791a..3174c221 100644 --- a/spec/readme-structure.md +++ b/spec/readme-structure.md @@ -4,13 +4,13 @@ The preferred `README.md` shape for a fleet project. The audit's `readme-structu ## Sections and Order -1. **Title (`# `)** - the H1 **is the repository name** (a hyphenated name may render its hyphens as spaces: `Financial-Modeling` -> `Financial Modeling`), then a one-line description as the next paragraph. That description is a **single sentence, link-free plain text, at most 100 characters** - it is the one canonical short description. It doubles as the GitHub About description (GOVERNANCE.md "Repository Details") and, for a repo that publishes a Docker image, the Docker Hub short description. Both render no markdown, and Docker Hub caps the short description near 100 characters - the tightest surface, which sets the limit. The audit checks the H1 name, the length, the link-free form, and the mirrors. +1. **Title (`# `)** - the H1 **is the repository name** (a hyphenated name may render its hyphens as spaces: `Financial-Modeling` -> `Financial Modeling`), then a one-line description as the next paragraph. That description is a **single sentence, link-free plain text, at most 100 characters**, and it is the one canonical short description. It doubles as the GitHub About description (GOVERNANCE.md "Repository Details") and, for a repo that publishes a Docker image, the Docker Hub short description. Both render no markdown, and Docker Hub caps the short description near 100 characters, the tightest surface, which sets the limit. The audit checks the H1 name, the length, the 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. It carries three sub-sections: - **Build Status (`###`)** - the CI/build status shields (release build, Docker build, last commit, last build). - **Releases (`###`)** - the version shields (GitHub release, GitHub pre-release, Docker latest/develop, NuGet, PyPI), one per channel the project publishes. - **Release Notes (`###`)** - the current version and a short summary, any breaking-change callout, then a link to `HISTORY.md` for the full history. -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**; 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 (`##`)** - generated by the Markdown All in One extension and auto-updated on save; 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. +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 (`##`)** - 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. **Use Cases (`##`)** - what problems it solves (optional for a library whose purpose is obvious from the description). 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. @@ -22,18 +22,24 @@ The preferred `README.md` shape for a fleet project. The audit's `readme-structu ## Shields -Shields are not a top-level section - they live under **Build and Distribution** as the **Build Status** and **Releases** sub-sections. Each shield is a reference-style link; group the definitions under `` at the bottom of the file. Stack a group's badges by ending each line except the last with a trailing backslash. +Shields are not a top-level section. They live under **Build and Distribution** as the **Build Status** and **Releases** sub-sections. Each shield is a reference-style link, with the definitions grouped under `` at the bottom of the file. Stack a group's badges by ending each line except the last with a trailing backslash. ## 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; no hard-wrap. +- One logical paragraph per line, with no hard-wrap. - Title-case headings with lowercase short bind words. -- US English, ASCII only (no em-dash; use a spaced hyphen), straight quotes. +- 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 - no cross-repo references and no template or inheritance framing. +- 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 by type under an HTML-comment header (``, ``, ``, ``) and alphabetized within each group. The auto-generated Table of Contents is the one exception, keeping inline anchor links. +## 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 one line saying what it holds, then a flat bullet list, each item stating the work and the reason it is owed. + +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 `HISTORY.md` is the maintainer-curated changelog and opens as the README's twin: the same `# ` (without the README's ToC-omit comment) and the same intro paragraph, copied verbatim, then a `## Release History` section. The mirrored opening keeps the project identity consistent for a reader who lands on the changelog directly, and the audit checks that the title and intro match the README (HTML comments stripped).