Skip to content

Promote the Tagline Close, the Ledger Refresh, and the Pre-Commit Doc Gates - #643

Merged
ptr727 merged 8 commits into
mainfrom
develop
Aug 9, 2026
Merged

Promote the Tagline Close, the Ledger Refresh, and the Pre-Commit Doc Gates#643
ptr727 merged 8 commits into
mainfrom
develop

Conversation

@ptr727

@ptr727 ptr727 commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Three commits, all backlog bookkeeping and local tooling. No rule changes meaning, and nothing here alters what CI gates.

#640, the tagline question. #577 asked that the tagline be a distinct thing from the intro so a README may carry a second paragraph without the cap or the mirrors reaching it. That shipped in c2ce145 (#632), so the issue is closed against it. Grepping the number before closing found TODO.md had also given #577 to a different entry, the README-to-About hop, which the issue's body never covers. That hop now carries #639 of its own.

#641, the divergence ledger. reports/divergences.md is a live pass over each repo's ground-truth branch, so a committed copy is only as current as its last run. Regenerating moved three rows: AGENTS.md "Fleet Bootstrap" is now divergent at Blog and HomeAutomation-Config, and GOVERNANCE.md "Verification Discipline" and "Workflow YAML Conventions" widened from one repo to four. The reason TODO.md gave for regenerating did not survive the run and is corrected. Two spent backlog claims went with it.

#642, the pre-commit hook. The chore was to reconsider whether the hook should run the doc gates now that they are diff-scoped. Measuring first found this repository had no hook at all, while GOVERNANCE.md and scripts/README.md both described what one runs. There is now a committed .husky/pre-commit running the prose gate diff-scoped and the line-ending check repo-wide, with the .gitattributes and .editorconfig pins that GOVERNANCE.md "Line Endings" makes mandatory for an extensionless hook.

The formatting half the fleet convention names is deliberately absent, and that is the finding worth carrying: this repository declares [tool.ruff] in pyproject.toml and spec/project-types.json declares python.ruff.config, yet no workflow runs ruff and the tree does not pass it, at 13 of 57 files unformatted and 106 lint errors. A ruff step would have blocked every commit from the moment it landed. The GOVERNANCE.md bullet now states the condition rather than a tool list, that a repo adds each half once its tree passes that half, and the measurement is recorded as a TODO.md chore.

Carried content in this promotion

GOVERNANCE.md "Running the Linters Locally" changed, and that section is carried verbatim, so every downstream copy is byte-mismatched until the next fleet visit. It rides the re-vendor entry already tracked under TODO.md "Fleet Sweeps" rather than owing a sweep of its own.

🤖 Generated with Claude Code

ptr727 and others added 3 commits August 9, 2026 08:55
Backlog bookkeeping for a question that is settled, and for a number
that was doing two jobs.

#577 asked that the tagline be stated as a distinct thing from the
intro, so a README may carry a second paragraph without the
100-character cap or the mirrors reaching it. That is what shipped in
`c2ce145` (#632): `spec/readme-structure.md` item 1 states that further
paragraphs below the tagline are free prose read by no mirror,
`CODESTYLE.md` item 4 narrows the `HISTORY.md` mirror to the tagline
alone, and `spec/audit.py` extracts the first line of the intro region
under a self-test that names the two-paragraph case. #577 is closed
against that evidence.

Grepping the number before closing it found it in a second place. "The
Declared Repository Description" had given #577 to its README-to-About
hop entry, which is work the issue's body never covers, so closing the
issue on its own correct evidence would have left that entry pointing at
a closed number. The hop now carries #639 of its own and the entry cites
it.

The "Recorded for the Maintainer" bullet that held #577 behind the
declared description is deleted rather than rewritten, because the
action it recorded is taken. Its reasoning did not survive the check
either: the extraction rule it feared writing already existed for the
About and Docker Hub mirrors, so narrowing the changelog mirror to match
it was one line rather than a new parser, which is recorded on the
closing comment so it is not re-derived.

## Verification

`scripts/prose_lint.py`, `scripts/repo_gate.py` (`eol`, `eol-coverage`,
`sha-pin`), `editorconfig-checker` and `markdownlint-cli2` all pass on
the working tree. The file is CRLF and stayed CRLF, which
`editorconfig-checker` is the gate that would have caught.

Closes #577

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
)

Maintenance, in three parts, none of which changes a rule.

## The ledger is regenerated

`reports/divergences.md` is generated by a live pass over each repo's
ground-truth branch, so a committed copy is only as current as its last
run, which was `3f91868` on 2026-08-06. This run moves three rows:
`AGENTS.md` "Fleet Bootstrap" is now divergent at Blog and
HomeAutomation-Config, and `GOVERNANCE.md` "Verification Discipline" and
"Workflow YAML Conventions" widen from one repo to four. The fleet
re-vendor sweep reads this file as its work list, so it wanted
regenerating before the sweep rather than during it.

The reason `TODO.md` gave for regenerating did not survive the run,
which is the more useful finding. It said the committed copy still
rendered `repo-config/configure.sh` under a re-vendor disposition that
the retirement decision replaced. That copy already carried `retire`, so
the warning described the decision rather than the file, and a session
acting on it would have gone looking for a row that was already correct.
The line now states why the file goes stale at all and records what this
run moved.

## Two spent claims are cleared

The "Verified Complete, Awaiting Close" entry for #578 is deleted, since
#578 closed on 2026-08-08. Deleting it loses nothing the fleet still
needs: the re-vendor entry under "Fleet Sweeps" already names all three
`GOVERNANCE.md` sections the #578 widening touches, and it keeps the
`[issue-578]` reference definition in use.

The README rework cluster's `State` read `backlog`, which is not one of
the four values `TODO.md` defines at the top of the file, so a reader
following the selection procedure had nothing to match it against. It is
now `decision`, on where `## Build Artifacts` belongs, which is the only
thing in that cluster a hub pull request settles. The four conformance
entries above it are named as landing on downstream visits, in the sense
"Fleet Sweeps" gives that phrase.

## Verification

`scripts/prose_lint.py` as CI invokes it (`. --check charset --check
semicolon ...`) exits 0, `scripts/repo_gate.py` reports `eol`,
`eol-coverage` and `sha-pin` clean, `editorconfig-checker` exits 0, and
`markdownlint-cli2` reports 0 issues over both files. Both files are
CRLF and stayed CRLF.

One note for a future run, since it cost a confusing minute here:
pointing `prose_lint.py` at `reports/divergences.md` **by path** reports
12 `dash` violations, because naming a file explicitly bypasses the
`GENERATED_TREES` exemption that holds `reports/` out of the corpus. The
generator's own phrasing is not an author's, which is what that
exemption is for, and the CI invocation scans `.` and is unaffected.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…642)

`TODO.md` carried a chore to reconsider whether the pre-commit hook
should run the doc gates now that they are diff-scoped. Measuring it
first, as the entry asked, found the premise was false for this
repository.

## There was no hook

`git ls-files .husky` returned nothing. `.husky/` held only
`_/husky.sh`, which is gitignored, and `core.hooksPath` sat in
`.git/config`, which no clone carries. Every commit in this tree ran
nothing.

Both [`GOVERNANCE.md`](../blob/develop/GOVERNANCE.md) and
[`scripts/README.md`](../blob/develop/scripts/README.md) described what
"the `.husky/pre-commit` hook" runs. That was true of the fleet
convention and not of this tree, which is the shape a reader has no way
to catch. The cataloged snippet would not have closed it either: it runs
`dotnet husky run`, and there is no .NET here.

## The measurement that answers the original question

| Command | Time |
| --- | --- |
| `prose_lint.py .` whole-tree, as CI runs it | 2.22 s |
| `prose_lint.py . --diff HEAD` | 0.13 s |
| `prose_lint.py . --diff HEAD~1` | 0.30 s |
| `repo_gate.py --check eol` | 0.04 s |

The standing decision to keep doc linters out was made against the
2.2-second sweep. What reverses it is the diff scope rather than a
change of preference, so both documents now say that rather than simply
asserting the new arrangement.

## What is deliberately absent

`repo_gate.py --check sha-pin` stays in CI. It measured 0.03 s here only
because all 45 pins are under another owner and were read for shape
alone. It resolves a same-owner pin against the GitHub API, and a hook
that needs a network fails offline and on a slow link.

The hook does not source `_/husky.sh`. That file is gitignored and
arrives with an npm install this repository does not have, so sourcing
it would break the hook in a fresh clone. The `.husky/` path is kept
regardless, because the fleet's line-ending rules are written against it
by name.

The hook reads the working tree rather than the index, so a partially
staged file is judged on all of its edits rather than only the staged
ones. That is stated in the hook and in `scripts/README.md` rather than
glossed, and CI's whole-tree run is the backstop that makes it
affordable.

## Line-ending pins

`GOVERNANCE.md` "Line Endings" makes both pins mandatory for a
repository that ships an extensionless hook, `.gitattributes` for git
and `.editorconfig` for the editor, since the git pin alone leaves the
editor free to write a CRLF shebang and break it. Both are added.
`eol-coverage` now reads 19 LF pins over 19 shebang files, up from 18
over 18.

## Verification

The hook rejected its own first draft, on 13 `comment-wrap` findings
across the comments I had just written, which is the exact failure the
chore named. Fixed and re-run, it exits 0, and it ran on the commit in
this pull request.

Full CI-equivalent set is clean: `prose_lint.py` with the CI check list
exits 0, `repo_gate.py` reports `eol`, `eol-coverage` and `sha-pin`
clean, `editorconfig-checker` exits 0, and `markdownlint-cli2` reports 0
issues across 44 files. The hook is committed `100755`, and is LF.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 9, 2026 17:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Updates local tooling and bookkeeping to reflect recent decisions: closes/renumbers backlog references, refreshes the generated divergence ledger, and adds a repo-local pre-commit hook that runs the diff-scoped prose gate plus the repo-wide EOL consistency check (with corresponding documentation and line-ending pins).

Changes:

  • Refresh backlog bookkeeping in TODO.md, including moving the About-hop entry to #639 and recording the ruff-convergence prerequisite before adding a ruff hook gate.
  • Regenerate reports/divergences.md to reflect current verbatim drift counts across the fleet.
  • Add .husky/pre-commit and update GOVERNANCE.md + scripts/README.md to describe the new hook behavior; pin the extensionless hook to LF in .gitattributes and .editorconfig.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
TODO.md Backlog bookkeeping updates: renumbering/closure notes and adding a ruff-convergence chore before enabling ruff in hooks.
scripts/README.md Updates script documentation to reflect that the pre-commit hook now runs two doc gates (diff-scoped prose gate + repo-wide EOL check).
reports/divergences.md Regenerated divergence ledger reflecting updated drift counts/rows.
GOVERNANCE.md Updates local-lint guidance to describe the hook running formatting + diff-scoped doc gates (and what remains CI-only).
.husky/pre-commit Adds a repo-local pre-commit hook running prose_lint.py --diff HEAD and repo_gate.py --check eol.
.gitattributes Pins .husky/pre-commit to LF to prevent CRLF shebang breakage.
.editorconfig Pins .husky/pre-commit to LF at the editor layer to match .gitattributes enforcement.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread .husky/pre-commit Outdated
Answers the one finding on the promotion pull request #643, which cannot
take a fix commit of its own: its head is `develop`, so the correction
reaches it by landing here first.

> The header comment says the hook runs doc checks "on the commit's own
scope", but the script later documents (and implements) scope as the
working tree diff against HEAD (not the index / staged content).

Accepted. The header named the staged content where the hook evaluates
the working tree diff against `HEAD`. The body of the same file already
said so, and said why, so the header was the single line disagreeing
both with the implementation and with the three sentences under it. That
is the worse shape of the two, since a reader who stops at the header
gets a wrong answer that the file itself contradicts further down.

It now says each gate runs at the scope that fits it, which is what the
file documents and what #642 settled: the prose gate is diff-scoped
against `HEAD`, and the line-ending check is repo-wide because it reads
`.gitattributes` against `.editorconfig` and takes no file list.

The hook ran on this commit and exits 0.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 9, 2026 18:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

…y Turn On (#644)

The mechanically safe slice of the ruff backlog `TODO.md` recorded, plus
the host floor that decides how much of that slice is safe.

> **Note on this description.** It originally described the first
commit, which declined `UP017` on the ground that no python3 floor was
declared. The maintainer then set the floor at 3.13, which reverses that
decision, and the second commit takes the rewrite instead. The body
below describes the change as it now stands. The superseded reasoning is
kept in the commit history rather than here, since a description that
argues for the opposite of its own diff is worse than one that is merely
terse.

## The ruff slice

This repository declares `[tool.ruff]` in `pyproject.toml` and
`spec/project-types.json` declares `python.ruff.config`, yet no workflow
runs ruff and the tree has never passed it. The full backlog is 106
findings plus a formatter that would rewrite 10449 lines across all 13
substantial Python files. This takes the auto-fixable findings only:
import ordering, `re.M` spelled `re.MULTILINE`, two
slice-to-`removeprefix` rewrites, an explicit f-string conversion, an
unused `noqa`, a pair of extraneous parentheses, and `UP017`. The 70
findings needing judgment stay open, as does the formatter.

Each fix pass exposed the next, so the set was run to convergence rather
than once. `UP017` rewrote the call sites and left `timezone` imported
but unused in three files, `F401` removed those imports, and that left
three import blocks unsorted for `I001`. A hand-picked rule set does not
settle in one pass the way the full set does.

## The python3 floor, at 3.13

`spec/host-tools.json` declared `"minimum": null` for python3, on the
stated ground that no floor had been measured. That was not true of the
tree: `str.removeprefix` and `str.removesuffix` are called in
`spec/audit.py` and `scripts/prose_lint.py` on `develop`, which is a 3.9
requirement that had been there unmeasured and undeclared, and is
exactly the gap that entry's own wording warns about.

The entry now declares `3.13` with the `source` block the schema
requires once a floor exists. It is a **target** floor rather than a
**measured** one, and the `why` says so outright: `pyproject.toml` sets
ruff to `py313` and mypy to `3.13`, so the lint and type results this
repo gates on describe 3.13 and describe no other interpreter, and a run
below the floor is unverified rather than known broken. The two measured
requirements are recorded beside it at 3.9 and 3.11, since those are
what an older interpreter actually fails on, as an `AttributeError` at
the call rather than at startup.

That distinction did not exist before this change, and four places
stated the single-kind rule that a floor exists only where a version is
known to break a documented procedure. All four now carry both kinds:
the note in `spec/host-tools.json`, the `minimum` description in
`spec/host-tools.schema.json`, `docs/host-setup.md` "Where a Tool Comes
From, and How Old It May Be", and `scripts/README.md`. Sweeping by the
term rather than by the instance is what found the last two.

## The test that failed, which is the test working

`scripts/test_host_gate.py` asserted the floor set as exactly `gh` and
`git-restore-mtime`, and it failed on this change. That assertion exists
so adding a floor is a deliberate edit in the test rather than a silent
one in the data, and it did that job. It now asserts the three-name set,
and a second case asserts that a target floor identifies itself in its
`why`, so a reader does not go hunting for a defect report that does not
exist.

## Verification

All 551 tests pass, `spec/validate.py` exits 0, `scripts/host_gate.py`
exits 0 reporting `python3 3.13.5 meets the 3.13 floor`, and both the
audit and write-guard self-tests report `SELFTEST PASS`. Every `.py`
line ending is unchanged, checked by recording all 13 files' endings
before and after and diffing the lists, since ruff rewrites in place and
this repository pins endings per path.

`prose_lint.py` with the CI check list exits 0, `repo_gate.py` reports
`eol`, `eol-coverage` and `sha-pin` clean, `editorconfig-checker` exits
0, and `markdownlint-cli2` reports 0 issues across 44 files.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 9, 2026 19:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated no new comments.

Suppressed comments (2)

scripts/test_host_gate.py:17

  • scripts/test_host_gate.py manipulates sys.path before importing host_gate, so ruff's default E402 (import not at top of file) will flag this once ruff is enforced. Since pyproject.toml already configures ruff, keep the existing pattern but add an explicit suppression so this file doesn't add new debt during ruff convergence.
sys.path.insert(0, str(Path(__file__).resolve().parent))
import host_gate

docs/host-setup.md:33

  • This section says python3 carries a 3.13 target floor and scripts/host_gate.py will enforce it as a hard minimum. The contract table above still says "Python 3", which can mislead a host on 3.12 into thinking it meets the requirement until the gate fails. Update the table row to reflect the enforced floor (3.13+).
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.

@ptr727

ptr727 commented Aug 9, 2026

Copy link
Copy Markdown
Owner Author

Answering both suppressed findings from the round on 9eb2510e, which carry no thread to resolve.

The contract table, accepted and widened

The contract table above still says "Python 3", which can mislead a host on 3.12 into thinking it meets the requirement until the gate fails.

Correct, and it is not confined to the row it names. The table answers presence, and a tool below its floor answers --version exactly like one above it, so a host on gh 2.46 reads that row and reaches the same wrong conclusion, and has been able to since the gh floor was declared. Fixing only the Python row would have left the identical trap one row up.

docs/host-setup.md now carries a Floor column across the whole table, naming the kind beside the number, since a measured floor gives a failing host a defect to point at and a target floor does not. Restating a number in prose is how a document drifts from the data it describes, so a test reads the shipped file and asserts every required tool's declared floor appears in a table row. Written first against the old table to confirm it fails, its first failure was a real finding, git-restore-mtime declares a floor and has no row, which is what scoped it to required tools.

That is in #646, since a promotion pull request's head is the branch being promoted and cannot carry a fix of its own.

The E402 suppression, declined again on the same evidence

scripts/test_host_gate.py manipulates sys.path before importing host_gate, so ruff's default E402 will flag this once ruff is enforced.

This is the same claim raised and answered on #644, whose change is one of the commits this promotion carries. Ruff's E402 deliberately exempts an import that follows sys.path manipulation, which is why RUF100 reported the suppression as unused rather than as load-bearing. Two constructed files isolate the exemption:

$ cat e402a.py
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
import json
$ uvx ruff check --isolated --select E402 e402a.py
All checks passed!

$ cat e402b.py
import sys

x = 1
import json
$ uvx ruff check --isolated --select E402 e402b.py
e402b.py:4:1: E402 Module level import not at top of file
Found 1 error.

Only the preceding statement differs. Adding the suppression back would put a noqa in the tree for a finding that does not occur, which RUF100 would then report, which is the loop the original removal ended.

Where the concern would be real is a pycodestyle-family linter, which has no such exemption. This repository runs none, and this promotion does not add one.

…646)

Answers one of the two suppressed findings on the promotion pull request
#643, which cannot take a fix of its own since its head is `develop`.

> This section says `python3` carries a 3.13 target floor and
`scripts/host_gate.py` will enforce it as a hard minimum. The contract
table above still says "Python 3", which can mislead a host on 3.12 into
thinking it meets the requirement until the gate fails.

Accepted, and widened to the class. The table answers presence, and a
tool below its floor answers `--version` exactly like one above it, so
every column reported a host as fine while `host_gate.py` failed it.
That was **already** true of `gh` before this session: a host on `2.46`
reads the `gh --version` row, concludes it qualifies, and then fails the
floor that has been declared since it was added. Patching only the row
the finding named would have left the same trap one row up, so the fix
is a `Floor` column covering both.

The kind is named beside the number, since the distinction decides what
a failing host can do next. A **measured** floor gives them a defect to
point at, and a **target** floor does not.

`git-restore-mtime` carries a floor and is deliberately absent from the
table, because it is optional and no procedure here needs it, which the
prose below the table already explains.

## The number is now restated in two places, so a test holds them
together

Copying a value out of `spec/host-tools.json` into prose creates exactly
the drift this column exists to prevent: the data moves, the table does
not, and a host reads the stale number and stops. A test now reads the
shipped `docs/host-setup.md` and asserts every **required** tool's
declared floor appears in a table row.

It was written before the table was correct, to confirm it fails rather
than assuming it would, and the first failure it produced was a real
finding rather than a bug in the test: `git-restore-mtime declares a
floor and has no row in the contract table`. That is what scoped the
assertion to required tools, and the reason is recorded in the test
rather than left to the next reader.

Confirmed it catches the case it exists for, by rewriting the table to
`3.12` and running it:

```console
AssertionError: False is not true : python3 declares 3.13 and no table row states it
```

## Verification

552 tests pass, `spec/validate.py` and `scripts/host_gate.py` exit 0,
`prose_lint.py` with the CI check list exits 0, `repo_gate.py` reports
all three checks clean, `editorconfig-checker` exits 0, and
`markdownlint-cli2` reports 0 issues across 44 files. The hook caught a
wrapped comment sentence in the new test before it landed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 9, 2026 19:38

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated no new comments.

Suppressed comments (1)

.husky/pre-commit:37

  • The hook invokes python3 directly, but docs/host-setup.md notes that on native Windows the python.org install registers py (and python) but not python3, so this hook will fail for Windows contributors running Git hooks via Git Bash. Consider falling back to py -3 when python3 is not on PATH so the hook matches the documented host contract probes.
python3 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.

@ptr727

ptr727 commented Aug 9, 2026

Copy link
Copy Markdown
Owner Author

Answering the suppressed finding from the round on 1286e800, which carries no thread to resolve.

The hook invokes python3 directly, but docs/host-setup.md notes that on native Windows the python.org install registers py (and python) but not python3, so this hook will fail for Windows contributors running Git hooks via Git Bash.

Accepted, and fixed before this promotion rather than after it, in #647 against develop.

This is the repository contradicting itself rather than an oversight about an untested platform. docs/host-setup.md devotes a paragraph to exactly this hazard, and spec/host-tools.json already encodes the answer as its probe order, [["python3", "--version"], ["py", "-3", "--version"]]. The hook restated a name its own contract documents as wrong on a supported platform, and I wrote it having read that paragraph earlier in this session.

Reproduced the shape rather than reasoning about Windows from Linux, on a PATH carrying a stand-in launcher and no python3:

$ PATH="$D" sh .husky/pre-commit        # fixed hook
[ok  ] eol          0 issue(s)
exit=0

$ PATH="$D" sh "$D/old-pre-commit"      # the hook as this promotion carries it
old-pre-commit: 32: python3: not found
exit=127

Being precise about the failure, since it changes how urgent this is: exit 127 is non-zero, so the current hook blocks the commit rather than passing it falsely. It is unusable on such a host rather than silently wrong, which is the better of the two failures and still not one to promote.

It is fixed on develop first because this pull request's head is develop and cannot carry a commit of its own. This promotion will pick it up on its next head.

Answers the suppressed finding on the promotion pull request #643, which
cannot take a fix of its own since its head is `develop`.

> The hook invokes `python3` directly, but docs/host-setup.md notes that
on native Windows the python.org install registers `py` (and `python`)
but not `python3`, so this hook will fail for Windows contributors
running Git hooks via Git Bash.

Accepted. This is the repository contradicting itself:
`docs/host-setup.md` states the hazard in its own paragraph, and
`spec/host-tools.json` already encodes the answer as its probe order,
`[["python3", "--version"], ["py", "-3", "--version"]]`. The hook
restated a name the contract says is wrong on one supported platform. It
now follows the same order rather than naming an interpreter.

## Checked on a PATH that has `py` and no `python3`

Rather than reasoning about Windows from Linux, the shape was
reproduced: a PATH carrying `git`, `sh` and a stand-in for the Windows
launcher, and no `python3` at all.

```console
$ PATH=$D command -v python3 || echo NO
NO
$ PATH="$D" sh .husky/pre-commit
[ok  ] eol          0 issue(s)
exit=0
```

The previous version on that same PATH:

```console
$ PATH="$D" sh "$D/old-pre-commit"
old-pre-commit: 32: python3: not found
exit=127
```

So this fixes a real break rather than hardening against a hypothetical
one. Worth being precise about the failure it was: exit 127 is non-zero,
so the old hook **blocked** the commit rather than passing it falsely.
The defect is that it was unusable on such a host, not that it was
silently wrong.

## With neither interpreter present

```console
pre-commit: no python3 and no py -3 on PATH, so the doc gates did not run.
pre-commit: see docs/host-setup.md 'What a Host Must Provide'.
exit=1
```

It names what did not run and points at the contract, and exits
non-zero. A hook that cannot run its gates must not report a clean
commit, which is the same rule `prose_lint.py` follows when it announces
a dropped rule rather than going quiet.

## Verification

552 tests pass, `prose_lint.py` with the CI check list exits 0, and
`repo_gate.py` reports `eol`, `eol-coverage` and `sha-pin` clean. The
hook ran on this commit.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 9, 2026 19:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated no new comments.

Suppressed comments (1)

spec/host-tools.json:53

  • The python3 tool's why text says older interpreters fail with an AttributeError "at the call rather than at startup", but spec/audit.py and scripts/pr_review.py now import UTC from datetime, which raises ImportError at import time on Python < 3.11. Updating this wording will keep the host contract accurate about the actual failure mode.
            "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, which gates the standard-library script tests and the prose and repo gates and nothing else, 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 and both sit lower: str.removeprefix and str.removesuffix need 3.9 and are called in spec/audit.py and scripts/prose_lint.py, and datetime.UTC needs 3.11 and is used in spec/audit.py, scripts/pr_review.py and their tests. Those are what an older interpreter actually fails on, as an AttributeError at the call rather than at startup. The name rather than the version is what differs per platform, which the second probe covers.",

@ptr727

ptr727 commented Aug 9, 2026

Copy link
Copy Markdown
Owner Author

Answering the suppressed finding from the round on bfef86a7, which carries no thread to resolve. Fixed on develop in #648.

The python3 tool's why text says older interpreters fail with an AttributeError "at the call rather than at startup", but spec/audit.py and scripts/pr_review.py now import UTC from datetime, which raises ImportError at import time on Python < 3.11.

Accepted. The sentence collapsed two requirements into one failure mode and was wrong for one of them. str.removeprefix is a method call and fails as described. datetime.UTC arrives through a module-level import in all three files, so an older interpreter raises before any of the module runs.

Both failures were constructed rather than described, since naming the symptom is the sentence's whole job:

import-time  : ImportError -> cannot import name '...' from 'datetime'
call-time    : AttributeError -> 'str' object has no attribute 'removeprefix'

This matters more than a wording nit because of where the text is read. why is what scripts/host_gate.py prints to a host that has just failed the floor. Telling that reader to expect an AttributeError partway through a run, when what they will see is an ImportError before anything starts, sends them looking for a different problem.

It also has a common cause with the finding two rounds ago about "the lint and type results this repo gates on". Both are the same entry claiming more than was checked, in the one field whose stated purpose is to keep a floor from becoming folklore. The entry is now three corrections deep, and each correction came from reading it against the tree rather than from re-reading the sentence.

The fix is on develop because this pull request's head is develop and cannot carry a commit of its own. This promotion will pick it up on its next head.

Answers the suppressed finding on the promotion pull request #643, which
cannot take a fix of its own since its head is `develop`.

> The python3 tool's `why` text says older interpreters fail with an
AttributeError "at the call rather than at startup", but spec/audit.py
and scripts/pr_review.py now import `UTC` from `datetime`, which raises
ImportError at import time on Python < 3.11.

Accepted. The sentence collapsed two requirements into one failure mode
and was wrong for one of them. `str.removeprefix` is a method call and
fails as the text described. `datetime.UTC` arrives through a
module-level import:

```console
$ grep -n "from datetime import" spec/audit.py scripts/pr_review.py scripts/test_pr_review.py
spec/audit.py:33:from datetime import UTC, datetime
scripts/pr_review.py:74:from datetime import UTC, datetime
scripts/test_pr_review.py:20:from datetime import UTC, datetime, timedelta
```

So on an older interpreter that module raises before any of it runs.

Constructed both failures rather than describing them, since the whole
point of the sentence is to name the symptom a reader is looking at:

```console
import-time  : ImportError -> cannot import name '...' from 'datetime'
call-time    : AttributeError -> 'str' object has no attribute 'removeprefix'
```

The entry now names each requirement with its own mode, and says the
import failure is the one a host meets first, because the script does
not start at all.

This matters more than a wording nit because of where the text is read.
`why` is what `scripts/host_gate.py` prints to a host that just failed
the floor. Telling that reader to expect an `AttributeError` partway
through a run, when what they will actually see is an `ImportError`
before anything runs, sends them looking for a different problem.

## Verification

`spec/validate.py` exits 0, `scripts/host_gate.py` exits 0 reporting
`python3 3.13.5 meets the 3.13 floor`, 552 tests pass, and
`prose_lint.py` with the CI check list exits 0.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 9, 2026 20:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated no new comments.

@ptr727
ptr727 requested a lite review from Copilot August 9, 2026 20:20
@ptr727
ptr727 merged commit 0a86bca into main Aug 9, 2026
8 checks passed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated no new comments.

Suppressed comments (1)

GOVERNANCE.md:371

  • The opening sentence says the .husky/pre-commit hook runs language formatting, but this repo's committed hook currently runs only doc gates (it explicitly documents the formatting half as absent). Consider rewording so the hook's behavior is described accurately even for repos mid-convergence (doc gates always; formatting once the tree passes/enables it).
- **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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants