diff --git a/.editorconfig b/.editorconfig
index 08aeec34..2ef0c084 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -60,6 +60,11 @@ end_of_line = lf
[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
diff --git a/.gitattributes b/.gitattributes
index 1c34a355..53f037cc 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -13,6 +13,8 @@
*.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.
diff --git a/.husky/pre-commit b/.husky/pre-commit
new file mode 100755
index 00000000..53028209
--- /dev/null
+++ b/.husky/pre-commit
@@ -0,0 +1,52 @@
+#!/bin/sh
+# Local pre-commit gate for this repository: the doc checks CI runs, each at the scope that fits it.
+# Enable it per clone with `git config core.hooksPath .husky`.
+# A clone carries no hooks path, so this file does nothing until that is set.
+# It deliberately does not source `_/husky.sh`.
+# That file is gitignored and arrives with an npm install this repository does not have.
+# Sourcing it would therefore break the hook in a fresh clone.
+# The path is kept for the fleet convention the line-ending pins are written against.
+#
+# The language-formatting half the fleet convention names is absent here, and measured rather than assumed.
+# This repository declares ruff in `pyproject.toml`, no workflow runs it, and the tree does not pass it.
+# `ruff format --check` reports 13 of 57 files would be reformatted and `ruff check` reports 106 errors.
+# A gate failing on the corpus it guards blocks every commit from the moment it lands.
+# Converging the Python comes first, and the step is added here after that rather than before it.
+#
+# `repo_gate.py --check sha-pin` is absent for a different reason.
+# It resolves same-owner pins against the GitHub API, and a hook needing a network fails offline.
+# The doc linters that need Docker stay in CI and in the VS Code Lint tasks.
+set -e
+
+# Git already runs a hook from the top level, measured by committing from `scripts/` and printing `pwd`.
+# This is belt and braces for an invocation that does not come from git.
+# The relative paths below would otherwise resolve against whatever directory the caller was in.
+cd "$(git rev-parse --show-toplevel)"
+
+# The interpreter is chosen by running the probes spec/host-tools.json declares, in its order.
+# On native Windows the python.org install registers `py` and not `python3`.
+# That name resolves to a Microsoft Store alias stub, and Git Bash inherits the Windows PATH.
+# The stub is on PATH and fails when run, so a presence test selects it and the hook then breaks.
+# Running the probe is the whole point: it is what tells a working interpreter from a name.
+if python3 --version >/dev/null 2>&1; then
+ run_py() { python3 "$@"; }
+elif py -3 --version >/dev/null 2>&1; then
+ run_py() { py -3 "$@"; }
+else
+ echo "pre-commit: neither 'python3 --version' nor 'py -3 --version' ran, so the doc gates did not run." >&2
+ echo "pre-commit: see docs/host-setup.md 'What a Host Must Provide'." >&2
+ exit 1
+fi
+
+# The prose gate is scoped to what changed against HEAD, which is the policy for prose.
+# A rule is applied as a file is next edited rather than swept across the tree.
+# Whole-tree costs about 2.2 seconds where the diff-scoped run costs about 0.13.
+# The scope is the working tree rather than the index.
+# A partially staged file is therefore judged on all of its edits, not only the staged ones.
+# CI re-runs the same rules over the whole tree, which is what makes that affordable here.
+run_py scripts/prose_lint.py . --diff HEAD
+
+# The eol check is repo-wide rather than diff-scoped, and it is here because it is already fast.
+# It reads `.gitattributes` against `.editorconfig` for the whole repository and takes no file list.
+# At about 0.04 seconds there is nothing to scope, so scoping it would only make it wrong.
+run_py scripts/repo_gate.py --check eol
diff --git a/GOVERNANCE.md b/GOVERNANCE.md
index 3d9bef33..534acd25 100644
--- a/GOVERNANCE.md
+++ b/GOVERNANCE.md
@@ -368,7 +368,7 @@ CI runs the full lint set, but run the linters locally before pushing to catch i
**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). 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 only**: CSharpier + `dotnet format` (or ruff) via native tooling, no Docker and no doc linters, so it stays fast.
+- **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.
diff --git a/TODO.md b/TODO.md
index afec764e..45e2a964 100644
--- a/TODO.md
+++ b/TODO.md
@@ -56,7 +56,7 @@ One pull request moving the canonical short description into declared data, so e
- **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** - [#577][issue-577], whose tagline half shipped on 2026-08-08.
+ - **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.
@@ -124,7 +124,7 @@ One pull request measuring the remaining carried surface against the carry-versu
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** `backlog`. **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.
+**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.
@@ -375,6 +375,7 @@ Small work with no research to preserve, selectable one bullet at a time.
- **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 and against a hook that runs the gate from the wrong directory, which is its own false clean.
- **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 beside it 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.
@@ -390,7 +391,7 @@ Work that lands on a downstream visit rather than as a hub pull request, so it i
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 the committed copy predates the retirement decision and renders `repo-config/configure.sh` under a re-vendor disposition that no longer applies to it. 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.
+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.
@@ -460,18 +461,12 @@ Regenerate [reports/divergences.md][divergences-report] before using it as the w
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.
-- **Comment on [#577][issue-577] that it is decided together with the declared description.** Declaring the field in [`registry/repos.json`][repos] makes every mirror read a field rather than parse a paragraph, so taking [#577][issue-577] first means writing an extraction rule the registry change then deletes.
## 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.
-- **[#578][issue-578], three rules that state the common case and leave the recurring one unstated.** Complete on all three items.
- - **Fixed by** - The pull request carrying this entry, since the fix and the entry ship in one squash and the closing comment cites that SHA.
- - **Checked** - `develop` at `a706ddb` on 2026-08-08, where all three gaps were re-read before the widening was written.
- - **Closing evidence** - [`GOVERNANCE.md`][governance] "Branching Model" now states that an issue closes when its work is verifiably complete and that the keyword automates the case where completion and promotion coincide rather than adding a condition to it, naming work complete on `develop` with no promotion imminent as the second hand-close case beside a promotion that merged without the keyword, which answers item 1. "Communicating with the User" now says the message carrying the clickable link comes **before** the prompt rather than merely alongside it, since a prompt blocks on an answer and a later message arrives after that answer is given, which answers item 2. "Operational Repositories" now states when to decline the direct-commit grant, as a shape rather than a line count, and records that it stays guidance because a `pull_request` rule on the operational ruleset would gate the direct push and withdraw the allowance, which answers item 3.
- - **Detail** - The item 1 rule sits in "Branching Model" rather than the "Git and Commit Rules" the retired cluster named, which is where the re-vendor has to look for it.
- - **Detail** - Sweeping item 3 by term rather than by the instance the issue named found [`WORKFLOW.md`][workflow] section 3 restating the same allowance with the same silence, so it now points at the section that owns the test rather than repeating it, which is one rule in one place and a cross-reference for the second reader.
+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.
@@ -498,6 +493,7 @@ Each was checked against the tree and has nothing left to do anywhere. Closing i
[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
diff --git a/docs/host-setup.md b/docs/host-setup.md
index 4c6366b9..96109f08 100644
--- a/docs/host-setup.md
+++ b/docs/host-setup.md
@@ -14,13 +14,15 @@ Supported hosts:
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, so this table stays true on every host.
-| Tool | Needed by | Present when |
-| --- | --- | --- |
-| `git` | everything, and the identity and signing contract in [`STANDUP.md`][standup] step 0 | `git --version` |
-| `gh` | the PR and review loop, `gh api` queries, `repo-config/configure.sh` | `gh --version` |
-| Python 3 | `scripts/` and `spec/` (standard library only, no packages to install) | `python3 --version`, or `py -3 --version` on native Windows |
-| `docker` | the four linters, which run as pinned images rather than local installs | `docker --version` |
-| `uv` / `uvx` | coverage runs, and the Python toolchain (`ruff`, `pyright` or `mypy`) in a Python repo | `uv --version` |
+| 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 |
+| `docker` | the four linters, which run as pinned images rather than local installs | `docker --version` | none |
+| `uv` / `uvx` | coverage runs, and the Python toolchain (`ruff`, `pyright` or `mypy`) in a Python repo | `uv --version` | none |
+
+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.
@@ -30,7 +32,7 @@ A missing tool is a host gap, not a repo problem. Install it and re-run, rather
### 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 exists only where a version is known to break a documented procedure, so most entries carry none, deliberately: a floor nobody can justify becomes a host failure nobody can act on.
+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.
diff --git a/host-setup/agent-safety/gh-write-guard.py b/host-setup/agent-safety/gh-write-guard.py
index 93ac819a..351265eb 100644
--- a/host-setup/agent-safety/gh-write-guard.py
+++ b/host-setup/agent-safety/gh-write-guard.py
@@ -46,17 +46,17 @@
| (?:label|secret|variable|ruleset)\s+(?:create|delete|edit|set)
| gist\s+(?:create|edit|delete)
)\b""",
- re.X,
+ re.VERBOSE,
)
_GH_API = re.compile(r"\bgh\s+api\b")
-_EXPLICIT_WRITE_METHOD = re.compile(r"(?:--method|-X)\s+(?:POST|PUT|PATCH|DELETE)\b", re.I)
+_EXPLICIT_WRITE_METHOD = re.compile(r"(?:--method|-X)\s+(?:POST|PUT|PATCH|DELETE)\b", re.IGNORECASE)
# A gh api call with a field flag defaults to POST even without -X, so it is a write.
_API_FIELD_FLAG = re.compile(r"(?:^|\s)(?:-f|-F|--field|--raw-field|--input)\b")
-_GRAPHQL = re.compile(r"\bgh\s+api\b.*\bgraphql\b", re.S)
+_GRAPHQL = re.compile(r"\bgh\s+api\b.*\bgraphql\b", re.DOTALL)
_MUTATION = re.compile(r"\bmutation\b")
# Loose pre-filter only: matches `git` before `push` even with global options between them
# (git -C
push). _push_arg_lists is the accurate arbiter that confirms an executable push.
-_GIT_PUSH = re.compile(r"\bgit\b.*?\bpush\b", re.S)
+_GIT_PUSH = re.compile(r"\bgit\b.*?\bpush\b", re.DOTALL)
# --- Bypass-of-branch-rule detectors (Rule 4) --------------------------------------------------------
# A git operation is denied when it would only succeed by bypassing an active branch rule.
diff --git a/host-setup/agent-safety/install.py b/host-setup/agent-safety/install.py
index 8858c02f..8594ad54 100644
--- a/host-setup/agent-safety/install.py
+++ b/host-setup/agent-safety/install.py
@@ -215,7 +215,7 @@ def reject(where, held, want):
newline, existing = "\n", ""
for marker, filename in blocks:
snippet = (HERE / filename).read_text(encoding="utf-8").strip()
- block_re = re.compile(rf".*?", re.S)
+ block_re = re.compile(rf".*?", re.DOTALL)
if block_re.search(existing):
existing, action = block_re.sub(lambda _: snippet, existing), "updated"
else:
diff --git a/reports/divergences.md b/reports/divergences.md
index 1ec747dc..e203949c 100644
--- a/reports/divergences.md
+++ b/reports/divergences.md
@@ -35,6 +35,7 @@ Generated by `python3 spec/fidelity_honesty.py --report` - do not hand-edit. Cur
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, HomeAutomation-Config
- **AGENTS.md > Context and Delegation Discipline** (1): Financial-Modeling
- **AGENTS.md > Where the Rules Live** (4): Blog, Financial-Modeling, HomeAutomation-Config, PhotoCleaner
- **GOVERNANCE.md > Foundational Principles** (1): Financial-Modeling
@@ -46,10 +47,10 @@ A past hub revision, not the current canonical - the audit already flags these a
- **GOVERNANCE.md > Operational Repositories** (4): Blog, Financial-Modeling, HomeAutomation-Config, PhotoCleaner
- **GOVERNANCE.md > Pull Request Title and Commit Message Conventions** (1): Financial-Modeling
- **GOVERNANCE.md > Documentation Style Conventions** (4): Blog, Financial-Modeling, HomeAutomation-Config, PhotoCleaner
-- **GOVERNANCE.md > Verification Discipline** (1): Financial-Modeling
+- **GOVERNANCE.md > Verification Discipline** (4): Blog, Financial-Modeling, HomeAutomation-Config, PhotoCleaner
- **GOVERNANCE.md > PR Review Etiquette** (4): Blog, Financial-Modeling, HomeAutomation-Config, PhotoCleaner
- **GOVERNANCE.md > Communicating with the User** (4): Blog, Financial-Modeling, HomeAutomation-Config, PhotoCleaner
-- **GOVERNANCE.md > Workflow YAML Conventions** (1): Financial-Modeling
+- **GOVERNANCE.md > Workflow YAML Conventions** (4): Blog, Financial-Modeling, HomeAutomation-Config, PhotoCleaner
- **GOVERNANCE.md > Supported Development Platforms** (1): Financial-Modeling
- **GOVERNANCE.md > Editor and Tasks** (1): Financial-Modeling
- **GOVERNANCE.md > Repository Details** (4): Blog, Financial-Modeling, HomeAutomation-Config, PhotoCleaner
diff --git a/scripts/README.md b/scripts/README.md
index 9d684e26..60956e28 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -1,6 +1,6 @@
# 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. Doc linters stay out of the pre-commit hook, which runs language formatting only so it stays fast.
+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.
@@ -119,7 +119,7 @@ A stale-backticked-path check was built and **rejected**: a template repo legiti
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 declared only where a version is known to break a documented procedure**, and each one records that defect rather than a preference. 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.
+**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.
diff --git a/scripts/host_gate.py b/scripts/host_gate.py
index 0a316c9c..7535ef79 100755
--- a/scripts/host_gate.py
+++ b/scripts/host_gate.py
@@ -23,7 +23,12 @@
below. 2 = the declaration itself could not be read, which is a defect here rather than on the host.
"""
from __future__ import annotations
-import argparse, json, re, subprocess, sys
+
+import argparse
+import json
+import re
+import subprocess
+import sys
from pathlib import Path
SPEC = Path(__file__).resolve().parent.parent / 'spec' / 'host-tools.json'
diff --git a/scripts/pr_review.py b/scripts/pr_review.py
index aad86bb7..fdae5b9c 100644
--- a/scripts/pr_review.py
+++ b/scripts/pr_review.py
@@ -62,8 +62,16 @@
GOVERNANCE.md "Repository Boundaries and Write Safety" for the rules `reply` enforces.
"""
from __future__ import annotations
-import argparse, io, json, re, subprocess, sys, tarfile, time
-from datetime import datetime, timezone
+
+import argparse
+import io
+import json
+import re
+import subprocess
+import sys
+import tarfile
+import time
+from datetime import UTC, datetime
from pathlib import Path
REVIEWER = 'copilot-pull-request-reviewer'
@@ -978,7 +986,7 @@ def checks_unreadable(pr: dict) -> bool:
than as this reading having failed. A silent narrowing is the failure mode this whole script
is built against, and it does not get an exception for its own newest field.
"""
- return bool(((pr.get('commits') or {}).get('nodes') or [])) and not head_commit(pr)
+ return bool((pr.get('commits') or {}).get('nodes') or []) and not head_commit(pr)
def checks_tally(nodes: list[dict]) -> tuple[int, int]:
@@ -1056,7 +1064,7 @@ def digest(owner: str, repo: str, num: int, seen: set[str] | None = None,
"""
pr = gql(Q_FULL, owner, repo, num) if pr is None else pr
stalled = stall_of(owner, repo, num, pr) if stalled is None else stalled
- now = datetime.now(timezone.utc) if now is None else now
+ now = datetime.now(UTC) if now is None else now
head = pr['headRefOid']
revs = reviewer_nodes(pr, 'reviews')
# `revs` is every round and `on_head` is the ones that reviewed this commit.
@@ -1664,7 +1672,7 @@ def main(argv: list[str] | None = None) -> int:
# The stall is re-read here rather than carried out of the loop.
# A request picked up since that reading would still report as picked up by nothing.
stalled = stall_of(owner, repo, a.number, final)
- now = datetime.now(timezone.utc)
+ now = datetime.now(UTC)
# Parsed here and handed down, so the digest and the exit code share one read of the rollup.
# Deriving the stuck shapes from that list costs no parse, which is what was doubled.
checks = check_nodes(final)
diff --git a/scripts/prose_lint.py b/scripts/prose_lint.py
index 8653a032..93ec7228 100644
--- a/scripts/prose_lint.py
+++ b/scripts/prose_lint.py
@@ -17,7 +17,14 @@
Exit 1 if any violation is found. Read-only, never edits.
"""
from __future__ import annotations
-import argparse, io, re, subprocess, sys, tokenize, unicodedata
+
+import argparse
+import io
+import re
+import subprocess
+import sys
+import tokenize
+import unicodedata
from pathlib import Path
from typing import NamedTuple, TypedDict
diff --git a/scripts/repo_gate.py b/scripts/repo_gate.py
index 228f8bb8..84bca21f 100644
--- a/scripts/repo_gate.py
+++ b/scripts/repo_gate.py
@@ -35,13 +35,17 @@
actionlint, editorconfig-checker, spec/validate.py).
"""
from __future__ import annotations
-import argparse, re, subprocess, sys
-from pathlib import Path, PurePosixPath
+
+import argparse
+import re
+import subprocess
+import sys
from fnmatch import fnmatch
+from pathlib import Path, PurePosixPath
# GOVERNANCE.md documents exactly one floating-ref exception.
SHA_EXCEPTIONS = {'dotnet/nbgv'}
-USES = re.compile(r'^\s*-?\s*uses:\s*(?P[[^\s#]+)', re.M)
+USES = re.compile(r'^\s*-?\s*uses:\s*(?P][[^\s#]+)', re.MULTILINE)
PIN = re.compile(r'^[0-9a-f]{40}$')
WORKFLOW = re.compile(r'workflows/.*\.ya?ml$')
# What `gh` prints when GitHub answered, as opposed to when nothing was reached at all.
diff --git a/scripts/test_host_gate.py b/scripts/test_host_gate.py
index db3a698c..bcc1ddc3 100755
--- a/scripts/test_host_gate.py
+++ b/scripts/test_host_gate.py
@@ -7,13 +7,15 @@
neither was visible from reading the code.
"""
from __future__ import annotations
+
import json
+import re
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
-import host_gate # noqa: E402
+import host_gate
def tool(name, minimum=None, required=True, probes=None, pattern=r'v(\d+(?:\.\d+)*)', **extra):
@@ -403,10 +405,76 @@ def test_a_floor_carries_a_source_so_the_finding_names_a_remedy(self):
if t['minimum'] is not None:
self.assertTrue(t.get('source'), f'{t["name"]} declares a floor and no source to install from')
- def test_the_two_known_defects_are_the_declared_floors(self):
- """A floor exists only where a defect is known, so this asserts the set rather than a count."""
+ def test_the_declared_floors_are_the_ones_with_a_stated_reason(self):
+ """A floor is justified or it is not there, so this asserts the set rather than a count.
+
+ Two kinds qualify. A measured floor sits above a version known to break a documented
+ procedure, and a target floor names the version the repo's toolchain is configured for.
+ The set is asserted so that adding a floor is a deliberate edit here rather than a silent
+ one in the data, which is what caught the python3 floor being added without this line.
+ """
floors = {t['name'] for t in self.data['tools'] if t['minimum'] is not None}
- self.assertEqual(floors, {'gh', 'git-restore-mtime'})
+ self.assertEqual(floors, {'gh', 'git-restore-mtime', 'python3'})
+
+ def test_the_contract_table_carries_every_declared_floor(self):
+ """docs/host-setup.md restates the floors, so the doc goes stale the moment the data moves.
+
+ The table's other columns answer presence, and a tool below its floor answers `--version`
+ like any other, so the Floor column is the only thing there that distinguishes present from
+ sufficient. A number that drifts out of step with the data is worse than an absent one,
+ since a host reads the table and stops.
+ """
+ def norm(text):
+ """A prose label reduced to the identifier it names, so `Python 3` keys as `python3`."""
+ return re.sub(r'[^a-z0-9.]', '', text.lower())
+
+ doc = (host_gate.SPEC.parent.parent / 'docs' / 'host-setup.md').read_text(encoding='utf-8')
+ rows, floor_col = {}, None
+ for ln in doc.splitlines():
+ cells = [c.strip() for c in ln.strip('|').split('|')] if ln.startswith('| ') else None
+ # Reading stops at the end of the contract table rather than at the end of the file.
+ # A later table's first column is a key like any other and would overwrite a tool row.
+ # The document carries a second table today whose keys collide with nothing.
+ # A test that depends on that is a test the next table silently breaks.
+ if floor_col is not None and cells is None:
+ break
+ if cells is None:
+ continue
+ # Every lookup here is on one named cell, never on the row and never on a fixed index.
+ # Searching the row matches the probe command in `Present when` as well.
+ # A table stating a version there would then satisfy this with the Floor column deleted.
+ # That is the failure this test exists to catch rather than to reproduce.
+ if floor_col is None:
+ if 'floor' in [c.lower() for c in cells]:
+ floor_col = [c.lower() for c in cells].index('floor')
+ continue
+ if cells:
+ rows[norm(cells[0])] = cells
+ self.assertIsNotNone(floor_col, 'docs/host-setup.md has no contract table with a Floor column')
+ for t in self.data['tools']:
+ # An optional tool is deliberately outside the table, which lists what a host must provide.
+ # `git-restore-mtime` carries a floor and no row, and that is correct.
+ if t['minimum'] is None or not t.get('required'):
+ continue
+ key = norm(t['name'])
+ self.assertIn(key, rows, f'{t["name"]} declares a floor and has no row in the contract table')
+ cells = rows[key]
+ self.assertGreater(len(cells), floor_col,
+ f'the {t["name"]} row has no Floor cell')
+ self.assertIn(t['minimum'], cells[floor_col],
+ f'{t["name"]} declares {t["minimum"]} and its Floor cell says '
+ f'{cells[floor_col]!r}')
+
+ def test_a_target_floor_says_so_rather_than_implying_a_defect(self):
+ """The python3 floor is a target, so its `why` has to distinguish itself from a measured one.
+
+ A reader who takes a target floor for a measured one goes looking for a defect report that
+ does not exist, which is the failure the two-kinds wording was written to prevent.
+ """
+ python3 = next(t for t in self.data['tools'] if t['name'] == 'python3')
+ self.assertEqual(python3['minimum'], '3.13')
+ self.assertIn('target', python3['why'])
+ self.assertIn('unverified rather than known broken', python3['why'])
if __name__ == '__main__':
diff --git a/scripts/test_pr_review.py b/scripts/test_pr_review.py
index 1c5217a0..eb0bb9ac 100644
--- a/scripts/test_pr_review.py
+++ b/scripts/test_pr_review.py
@@ -9,8 +9,15 @@
Run as `python3 scripts/test_pr_review.py`, or under `python3 -m unittest discover -s scripts`.
"""
from __future__ import annotations
-import contextlib, io, json, re, subprocess, sys, unittest
-from datetime import datetime, timedelta, timezone
+
+import contextlib
+import io
+import json
+import re
+import subprocess
+import sys
+import unittest
+from datetime import UTC, datetime, timedelta
from itertools import count
from pathlib import Path
from unittest import mock
@@ -105,7 +112,7 @@ def thread(tid: str, resolved: bool = False, login: str = pr_review.REVIEWER,
# A fixed clock, so a case holds a check at a known age instead of at whatever the suite runs at.
-NOW = datetime(2026, 8, 6, 17, 0, 0, tzinfo=timezone.utc)
+NOW = datetime(2026, 8, 6, 17, 0, 0, tzinfo=UTC)
def ago(seconds: int) -> str:
@@ -121,7 +128,7 @@ def ago(seconds: int) -> str:
def real_ago(seconds: int) -> str:
"""A timestamp `seconds` before the real clock, for the `wait` path, which reads that clock."""
- return (datetime.now(timezone.utc) - timedelta(seconds=seconds)).strftime('%Y-%m-%dT%H:%M:%SZ')
+ return (datetime.now(UTC) - timedelta(seconds=seconds)).strftime('%Y-%m-%dT%H:%M:%SZ')
def check(name: str = 'Check pull request workflow status job', status: str = 'COMPLETED',
diff --git a/scripts/test_prose_lint.py b/scripts/test_prose_lint.py
index 96765401..2847103f 100644
--- a/scripts/test_prose_lint.py
+++ b/scripts/test_prose_lint.py
@@ -8,7 +8,15 @@
Run as `python3 scripts/test_prose_lint.py`, or under `python3 -m unittest discover -s scripts`.
"""
from __future__ import annotations
-import contextlib, io, json, re, subprocess, sys, tempfile, unittest
+
+import contextlib
+import io
+import json
+import re
+import subprocess
+import sys
+import tempfile
+import unittest
from pathlib import Path
from unittest import mock
@@ -129,14 +137,14 @@ class TestGovernanceCoupling(unittest.TestCase):
def setUp(self) -> None:
self.doc = GOVERNANCE.read_text(encoding='utf-8')
- section = re.search(r'^### Character Set$(.*?)^### ', self.doc, re.M | re.S)
+ section = re.search(r'^### Character Set$(.*?)^### ', self.doc, re.MULTILINE | re.DOTALL)
if section is None:
self.fail('the Character Set heading moved, so the parse is blind')
self.section = section.group(1)
def tier_codepoints(self, label: str) -> set[int]:
"""Codepoints named in one tier's bullet, read out of the rule text itself."""
- m = re.search(rf'^- \*\*Tier {label},(.*?)(?=^- \*\*)', self.section, re.M | re.S)
+ m = re.search(rf'^- \*\*Tier {label},(.*?)(?=^- \*\*)', self.section, re.MULTILINE | re.DOTALL)
if m is None:
self.fail(f'the Tier {label} bullet moved, so the parse is blind')
return {int(h, 16) for h in re.findall(r'U\+([0-9A-Fa-f]{4})', m.group(1))}
diff --git a/scripts/test_repo_gate.py b/scripts/test_repo_gate.py
index fa36a1e8..e82cb96a 100644
--- a/scripts/test_repo_gate.py
+++ b/scripts/test_repo_gate.py
@@ -8,7 +8,15 @@
Run as `python3 scripts/test_repo_gate.py`, or under `python3 -m unittest discover -s scripts`.
"""
from __future__ import annotations
-import contextlib, io, re, shutil, subprocess, sys, tempfile, unittest
+
+import contextlib
+import io
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+import unittest
from pathlib import Path
from unittest import mock
diff --git a/spec/audit.py b/spec/audit.py
index de4faec5..f1015954 100644
--- a/spec/audit.py
+++ b/spec/audit.py
@@ -30,7 +30,7 @@
import sys
import urllib.error
import urllib.request
-from datetime import datetime, timezone
+from datetime import UTC, datetime
from typing import Any
ROOT = pathlib.Path(__file__).resolve().parent.parent
@@ -162,7 +162,7 @@ def driftnote_findings(entry, spec, open_count):
out.append(("DRIFT", f"registry: driftNote names check '{cid}', whose type '{owner}' this repo does not declare: {quoted}"))
else:
out.append(("DRIFT", f"registry: driftNote names check '{cid}', which this audit does not evaluate by id (AUDIT.md section 4) - judge it by hand and delete the note once it passes: {quoted}"))
- marker = next((w for w in PENDING_MARKERS if re.search(rf"\b{re.escape(w)}\b", note, re.I)), None)
+ marker = next((w for w in PENDING_MARKERS if re.search(rf"\b{re.escape(w)}\b", note, re.IGNORECASE)), None)
if marker and not open_count:
out.append(("DRIFT", f"registry: driftNote says '{marker}' but the audit is clean - verify and reconcile: {quoted}"))
elif marker:
@@ -310,7 +310,7 @@ def heading_texts(markdown):
return {m.group(1).strip().lower() for line in markdown.splitlines() for m in (_HEADING.match(line),) if m}
-_HTML_COMMENT = re.compile(r"", re.S)
+_HTML_COMMENT = re.compile(r"", re.DOTALL)
_MD_LINK_INLINE = re.compile(r"\[([^\]]*)\]\((?:[^()]|\([^()]*\))*\)") # URL may hold one level of ()
_MD_LINK_REF = re.compile(r"\[([^\]]*)\]\[[^\]]*\]")
@@ -363,10 +363,10 @@ def tagline(intro):
_MD_IMAGE_REF = re.compile(r"!\[[^\]]*\]\[([^\]]+)\]")
_MD_IMAGE_INLINE = re.compile(r"!\[[^\]]*\]\((\S+?)\)")
-_LINK_DEF = re.compile(r"^\[([^\]]+)\]:\s*(\S+)", re.M)
+_LINK_DEF = re.compile(r"^\[([^\]]+)\]:\s*(\S+)", re.MULTILINE)
# A URI scheme, requiring two or more characters so a `C:` drive letter is not read as one.
# Every scheme a README actually carries (mailto, ftp, ssh, git, tel, data) is longer than that.
-_URI_SCHEME = re.compile(r"[a-z][a-z0-9+.\-]+:", re.I)
+_URI_SCHEME = re.compile(r"[a-z][a-z0-9+.\-]+:", re.IGNORECASE)
def unfenced_text(text):
@@ -711,10 +711,8 @@ def table_cells(line):
s = line.strip()
if "|" not in s:
return None
- if s.startswith("|"):
- s = s[1:]
- if s.endswith("|"):
- s = s[:-1]
+ s = s.removeprefix("|")
+ s = s.removesuffix("|")
return [c.strip() for c in s.split("|")]
@@ -1210,7 +1208,7 @@ def audit_repo(entry, spec, branch=None):
# Language ecosystems such as nuget, uv and npm are directory-scoped and not yet cross-checked here.
db = gh(f"repos/{slug}/contents/.github/dependabot.yml?ref={ground}", ok404=True)
if db and db.get("content"):
- declared = set(re.findall(r'^[ \t]*-?[ \t]*package-ecosystem:[ \t]*["\']?([\w-]+)', base64.b64decode(db["content"]).decode("utf-8", "replace"), re.M))
+ declared = set(re.findall(r'^[ \t]*-?[ \t]*package-ecosystem:[ \t]*["\']?([\w-]+)', base64.b64decode(db["content"]).decode("utf-8", "replace"), re.MULTILINE))
implied = {}
workflows = gh(f"repos/{slug}/contents/.github/workflows?ref={ground}", ok404=True)
if isinstance(workflows, list) and any(e["name"].endswith((".yml", ".yaml")) for e in workflows):
@@ -1475,7 +1473,7 @@ def _selftest():
got = classify_verbatim(down, canon_t, history)
if got != want:
ok = False
- print(f" {'ok ' if got == want else 'FAIL'} want={str(want):>8} got={str(got):>8} verbatim: {label}")
+ print(f" {'ok ' if got == want else 'FAIL'} want={want!s:>8} got={got!s:>8} verbatim: {label}")
# Action-pin neutralization, where a Dependabot uses:@ bump, meaning both the 40-hex sha and its ` # vN` comment, must not count as verbatim drift, while a changed action name must.
# This is what lets a verbatim workflow region survive routine action bumps while still catching a real fork.
pin_a = " - uses: actions/checkout@" + "a" * 40 + " # v7.0.0\n"
@@ -1713,7 +1711,7 @@ def _selftest():
"[license-shield]: https://img.shields.io/github/license/o/r\n"
)
# The four repos that suffix every heading for the ToC extension must read identically to the plain form.
- omit_toc = re.sub(r"^(#{2,3} .*)$", r"\1 ", conformant, flags=re.M)
+ omit_toc = re.sub(r"^(#{2,3} .*)$", r"\1 ", conformant, flags=re.MULTILINE)
readme_cases = [
("a conformant README, repo-specific section included", conformant, set(), True, 0),
("a second intro paragraph is not a finding", conformant.replace("A fixture repository.\n", "A fixture repository.\n\nAnd a clarifying paragraph about it.\n"), set(), True, 0),
@@ -2043,7 +2041,7 @@ def main(argv=None):
# Findings are a point-in-time snapshot.
# Stamp the run so anything derived from it, an onboarding issue or a report, carries its own freshness signal and a reader can tell whether it still applies.
- run_utc = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+ run_utc = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
hub = subprocess.run(["git", "rev-parse", "--short", "HEAD"], capture_output=True, text=True, cwd=ROOT)
hub_sha = hub.stdout.strip() if hub.returncode == 0 else "unknown"
diff --git a/spec/host-tools.json b/spec/host-tools.json
index 960c6204..874b00b3 100644
--- a/spec/host-tools.json
+++ b/spec/host-tools.json
@@ -1,6 +1,6 @@
{
"$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 exists only where a specific version is known to break a documented procedure, so most entries carry none and are 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.",
+ "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.",
"tools": [
{
"name": "docker",
@@ -49,8 +49,13 @@
"required": true,
"probes": [["python3", "--version"], ["py", "-3", "--version"]],
"pattern": "Python (\\d+(?:\\.\\d+)*)",
- "minimum": null,
- "why": "Every script here is standard library only, so a bare interpreter is enough and no package floor exists. No version floor is declared because none has been measured, and the name rather than the version is what differs per platform, which the second probe covers."
+ "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. Neither tool runs in CI. What CI does run is markdownlint, cspell, actionlint and editorconfig-checker, the registry and spec validation, the script self-tests, and the repo and prose gates, and no Python linter or type checker among them. So this floor is 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."
+ }
},
{
"name": "uv",
diff --git a/spec/host-tools.schema.json b/spec/host-tools.schema.json
index 90520d54..3c9e4fbf 100644
--- a/spec/host-tools.schema.json
+++ b/spec/host-tools.schema.json
@@ -63,7 +63,7 @@
"minimum": {
"type": ["string", "null"],
"pattern": "^\\d+(\\.\\d+)*$",
- "description": "The lowest acceptable version as dot-separated integers, or null where no floor has been measured. A floor is declared only where a version is known to break a documented procedure."
+ "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",
diff --git a/spec/validate.py b/spec/validate.py
index c14096fa..c9623bc9 100644
--- a/spec/validate.py
+++ b/spec/validate.py
@@ -429,7 +429,7 @@ def check_selector(where, applies_to):
# This is Markdown only, and only where the hub ships the file.
if path.endswith(".md") and (ROOT / path).exists():
hub_text = (ROOT / path).read_text(encoding="utf-8", errors="replace")
- headings = {m.group(1).strip() for m in re.finditer(r"^## (.+?)\s*$", hub_text, re.M)}
+ headings = {m.group(1).strip() for m in re.finditer(r"^## (.+?)\s*$", hub_text, re.MULTILINE)}
for elt in sections:
name = elt.get("name") if isinstance(elt, dict) else elt
if isinstance(name, str) and name and name not in headings:
]