Project-Template/docs/architecture/scripts/doc-triggers.py

387 lines
16 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Which documents does a change fire, while there is still time to update them?
python3 scripts/doc-triggers.py # everything dirty right now
python3 scripts/doc-triggers.py --staged # what is staged
python3 scripts/doc-triggers.py <path> [path] # specific paths
python3 scripts/doc-triggers.py --range HEAD~3..HEAD
## The failure this catches
Every document in this tree carries a `Review trigger:` the change that should
send somebody back to it and `WORK_CYCLE.md` requires the triggered documents
to be updated *in the same commit as the code*. Deciding which fired means
reading every `Governs:` line and matching globs in your head, once per commit.
It is a check with no output of its own, so it is the one that gets skipped when
the code is already green, and the cost is invisible until a reader trusts a
document that stopped being true: a reference manual six migrations behind, and
every reader in between believing it.
**This is not the doc-review check.** That one asks which baselined documents are
*overdue*, from committed history a governed path with a commit newer than the
document's `Last reviewed` date. It is a different question and it can only be
asked after the fact. By the time a change is committed without its document, the
thing this catches has already happened.
Exit status is always 0. This is a prompt, not a gate: a trigger asks a human
whether the prose is still true, and a check that failed the build for that would
be bumped past rather than read.
fix(tools): doc-triggers matched the glob but not the trigger's verb DOC_TRUST_MAP.md declares `Governs: docs/**`, the broadest glob in the tree, while its Review trigger is one of the narrowest -- any doc added, deleted or moved. Matching on the glob alone fired it on every edit to every document, forever, and correctly by the only rule the tool had. Touching one script fired three documents and exactly one of them applied. A prompt that always fires is one people stop reading, and it takes the true positives with it. This tool exits 0 by design -- it is a prompt, not a gate -- which makes it more vulnerable to that, not less, because nothing forces the reading. Documents now declare the kinds of change their trigger names, in an optional `Fires on:` header field, read against git's own status letter. Absent, empty or unparseable means every kind, so nothing changes for the six other path-governing documents and a document is only ever quietened by somebody writing the line deliberately. ## Why declared rather than read out of the trigger prose The obvious first cut is to look for added/deleted/moved with no changed/change to. Tried against the seven path-governing documents here, it misclassifies the one it exists to fix: DOC_TRUST_MAP.md's trigger ends "any change to which doc owns a subject", so it reads as a change-verb. That clause is about which document owns a subject, not about a file being edited, and nothing lexical separates it from architecture/README.md's "any change to a module boundary or a data shape", which genuinely does mean modification. Guessing at English is silent in the expensive direction: a document wrongly read as existence-only stops being prompted for and goes quietly stale, which is the failure this whole tool exists to prevent. So the narrowing is declared or it does not happen. ## Also changed_paths now carries a status letter per path, from --name-status for --staged and --range and from the porcelain columns for the working tree. Paths named on the command line have no diff to read, so the kind is inferred: absent from disk is a deletion, present but untracked is an addition, otherwise a modification. Documents that govern a path in the change but do not fire on its kind are named in their own short block rather than dropped, because a reader who saw nothing would have to guess whether they had been considered. The no-match message now distinguishes "nothing governs these paths" from "governed, but not this kind of change" -- the second is a declaration somebody wrote, not an unclaimed area. Verified: modifying a script fires TOOLS.md and architecture/README.md and not DOC_TRUST_MAP.md; adding, deleting and moving a document under docs/ each still fire it; modifying a document fires nothing; an unknown word warns and fires on everything; an empty or absent field fires on everything. closes #20 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:50:21 -05:00
## Matching the trigger's verb, not only its glob
A document is fired when a changed path matches its `Governs:` **and** the kind
of change matches its `Fires on:`. That second field is optional and almost
never needed; it exists for the case where `Governs:` is far broader than the
trigger. `DOC_TRUST_MAP.md` is the extreme it governs `docs/**` while its
trigger is *any doc added, deleted or moved* so on the glob alone it fired on
every edit to every document, forever, and correctly by the only rule there was.
A prompt that always fires is one people stop reading, and this one exits 0 by
design, so nothing forces the reading.
The kinds are `added`, `deleted`, `moved` and `changed`, read from git's own
status letter. Absent, empty or unparseable means every kind, which is the
behaviour every document without the line still has.
## Two things it deliberately does not do
**It does not read `Exempt:` declarations.** Those mark a *required* document as
deliberately absent from a repository, and a document that does not exist cannot
govern a path. There is nothing here for them to change.
**It cannot fire a document whose `Governs:` is prose.** Several in this template
govern a subject rather than a set of paths `GUARDS.md` governs "structural
tests, source-grep assertions, probes, and any check whose passing is taken as
evidence", which is the honest description and matches no glob. Those documents
are listed separately at the end rather than silently ignored, because a reader
who sees only the matched list would reasonably conclude the others were checked
and cleared.
"""
from __future__ import annotations
import fnmatch
import pathlib
import re
import subprocess
import sys
fix(tools): doc-triggers found the repository by depth and left it ROOT was `Path(__file__).resolve().parents[3]`, which is correct only while the script sits at its template home, docs/architecture/scripts/. TOOLS.md tells an adopting project to take scripts one at a time into its own scripts/, and from <project>/scripts/doc-triggers.py that expression resolves to the *parent of the project* -- outside the repository entirely. The failure is silent and reads as a pass. main() opens with a `not DOCS.is_dir()` guard that prints and returns 0, so an adopting project got exit 0 and one line naming a directory two levels above the code it was asked about. The check that enforces "update the triggered documents in the same commit as the code" had quietly stopped running, in exactly the projects that took the template's advice. That is the failure GUARDS.md opens with -- a guard that cannot fail is worse than no guard, because it is trusted -- landed on the tool that polices the documents. It could not be caught by running it here, because here parents[3] is right; it takes a copy at the documented location to see it. The root is now found rather than assumed: walk up from __file__ for a directory holding both docs/ and .git, then either alone, then `git rev-parse --show-toplevel`, then give up to the script's own parent. Both directories are required before docs/ alone so that a repository vendoring a docs/ in some subdirectory does not anchor on it. Reproduced in a scratch repository with the script at scripts/, a document governing src/**, and src/a.py staged: before, "no docs/ directory at <tmp>/docs"; after, the document and its trigger. The in-place layout is unchanged and still fires DOC_TRUST_MAP.md, TOOLS.md and architecture/README.md. Exit status is still always 0. This is a prompt, not a gate, so the fix belongs in root resolution and not in the exit code. closes #17 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:39:13 -05:00
def _find_root() -> pathlib.Path:
"""The repository root, found rather than assumed.
This was `parents[3]`, which is correct only while the script sits at
`docs/architecture/scripts/` its home in the template. The moment a
project copies it to `scripts/`, as the template's own adoption
instructions say to, `parents[3]` climbs out of the repository entirely: in
a checkout at `~/Projects/thing/scripts/`, it resolves to `~/`, and the
script reports "no docs/ directory" about a directory two levels above the
project it was run in.
So: walk up from the script looking for a directory that has both `docs/`
and `.git`, then fall back to either alone, then to git's own answer.
"""
here = pathlib.Path(__file__).resolve()
for parent in here.parents:
if (parent / "docs").is_dir() and (parent / ".git").exists():
return parent
for parent in here.parents:
if (parent / "docs").is_dir():
return parent
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
cwd=here.parent,
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0 and result.stdout.strip():
return pathlib.Path(result.stdout.strip())
return here.parents[1]
ROOT = _find_root()
DOCS = ROOT / "docs"
# The status header is a fenced block immediately after the H1, and a long value
# wraps onto continuation lines indented by two spaces:
#
# Governs: structural tests, source-grep assertions, probes, and any check whose
# passing is taken as evidence
#
# A regex that reads one line per field — the obvious implementation — truncates
# at the wrap and silently under-reports, which for this tool means quietly
# failing to name a document that should have been updated. So fields are
# assembled line by line instead.
fix(tools): doc-triggers matched the glob but not the trigger's verb DOC_TRUST_MAP.md declares `Governs: docs/**`, the broadest glob in the tree, while its Review trigger is one of the narrowest -- any doc added, deleted or moved. Matching on the glob alone fired it on every edit to every document, forever, and correctly by the only rule the tool had. Touching one script fired three documents and exactly one of them applied. A prompt that always fires is one people stop reading, and it takes the true positives with it. This tool exits 0 by design -- it is a prompt, not a gate -- which makes it more vulnerable to that, not less, because nothing forces the reading. Documents now declare the kinds of change their trigger names, in an optional `Fires on:` header field, read against git's own status letter. Absent, empty or unparseable means every kind, so nothing changes for the six other path-governing documents and a document is only ever quietened by somebody writing the line deliberately. ## Why declared rather than read out of the trigger prose The obvious first cut is to look for added/deleted/moved with no changed/change to. Tried against the seven path-governing documents here, it misclassifies the one it exists to fix: DOC_TRUST_MAP.md's trigger ends "any change to which doc owns a subject", so it reads as a change-verb. That clause is about which document owns a subject, not about a file being edited, and nothing lexical separates it from architecture/README.md's "any change to a module boundary or a data shape", which genuinely does mean modification. Guessing at English is silent in the expensive direction: a document wrongly read as existence-only stops being prompted for and goes quietly stale, which is the failure this whole tool exists to prevent. So the narrowing is declared or it does not happen. ## Also changed_paths now carries a status letter per path, from --name-status for --staged and --range and from the porcelain columns for the working tree. Paths named on the command line have no diff to read, so the kind is inferred: absent from disk is a deletion, present but untracked is an addition, otherwise a modification. Documents that govern a path in the change but do not fire on its kind are named in their own short block rather than dropped, because a reader who saw nothing would have to guess whether they had been considered. The no-match message now distinguishes "nothing governs these paths" from "governed, but not this kind of change" -- the second is a declaration somebody wrote, not an unclaimed area. Verified: modifying a script fires TOOLS.md and architecture/README.md and not DOC_TRUST_MAP.md; adding, deleting and moving a document under docs/ each still fire it; modifying a document fires nothing; an unknown word warns and fires on everything; an empty or absent field fires on everything. closes #20 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:50:21 -05:00
FIELD_START = re.compile(
r"^(Status|Owner|Last reviewed|Governs|Review trigger|Fires on):\s*(.*)$"
)
HEADER_LINES = 16
def header_of(doc: pathlib.Path) -> dict[str, str]:
"""The status header, with wrapped values joined."""
try:
lines = doc.read_text(encoding="utf-8").splitlines()[:HEADER_LINES]
except (OSError, UnicodeDecodeError):
return {}
fields: dict[str, str] = {}
current: str | None = None
for line in lines:
match = FIELD_START.match(line)
if match:
current = match.group(1)
fields[current] = match.group(2).strip()
elif current and line.startswith((" ", "\t")) and line.strip():
fields[current] = f"{fields[current]} {line.strip()}".strip()
elif line.strip().startswith("```") and fields:
break
return fields
def looks_like_path(glob: str) -> bool:
"""Whether a `Governs:` entry is a path pattern rather than a subject.
The same test `doc-claims.sh` uses: a slash, a wildcard, or a file
extension. Prose about what a document is authoritative for will have none
of them, and must not be treated as a glob that simply never matches.
"""
glob = glob.strip()
if not glob or " " in glob and "/" not in glob and "*" not in glob:
return False
return "/" in glob or "*" in glob or re.search(r"\.\w{1,5}$", glob) is not None
def matches(path: str, glob: str) -> bool:
"""Whether `path` is governed by `glob`.
`**` means "and everything below", which `fnmatch` does not implement: its
`*` already crosses separators, so `a/**` never matches `a/b/c`. The two
forms these headers actually use are reduced to prefix tests.
"""
glob = glob.strip()
if not glob:
return False
if glob.endswith("/**"):
return path.startswith(glob[:-2]) or path == glob[:-3]
if "/**/" in glob:
head, tail = glob.split("/**/", 1)
return path.startswith(head + "/") and fnmatch.fnmatch(path, "*" + tail)
return fnmatch.fnmatch(path, glob)
fix(tools): doc-triggers matched the glob but not the trigger's verb DOC_TRUST_MAP.md declares `Governs: docs/**`, the broadest glob in the tree, while its Review trigger is one of the narrowest -- any doc added, deleted or moved. Matching on the glob alone fired it on every edit to every document, forever, and correctly by the only rule the tool had. Touching one script fired three documents and exactly one of them applied. A prompt that always fires is one people stop reading, and it takes the true positives with it. This tool exits 0 by design -- it is a prompt, not a gate -- which makes it more vulnerable to that, not less, because nothing forces the reading. Documents now declare the kinds of change their trigger names, in an optional `Fires on:` header field, read against git's own status letter. Absent, empty or unparseable means every kind, so nothing changes for the six other path-governing documents and a document is only ever quietened by somebody writing the line deliberately. ## Why declared rather than read out of the trigger prose The obvious first cut is to look for added/deleted/moved with no changed/change to. Tried against the seven path-governing documents here, it misclassifies the one it exists to fix: DOC_TRUST_MAP.md's trigger ends "any change to which doc owns a subject", so it reads as a change-verb. That clause is about which document owns a subject, not about a file being edited, and nothing lexical separates it from architecture/README.md's "any change to a module boundary or a data shape", which genuinely does mean modification. Guessing at English is silent in the expensive direction: a document wrongly read as existence-only stops being prompted for and goes quietly stale, which is the failure this whole tool exists to prevent. So the narrowing is declared or it does not happen. ## Also changed_paths now carries a status letter per path, from --name-status for --staged and --range and from the porcelain columns for the working tree. Paths named on the command line have no diff to read, so the kind is inferred: absent from disk is a deletion, present but untracked is an addition, otherwise a modification. Documents that govern a path in the change but do not fire on its kind are named in their own short block rather than dropped, because a reader who saw nothing would have to guess whether they had been considered. The no-match message now distinguishes "nothing governs these paths" from "governed, but not this kind of change" -- the second is a declaration somebody wrote, not an unclaimed area. Verified: modifying a script fires TOOLS.md and architecture/README.md and not DOC_TRUST_MAP.md; adding, deleting and moving a document under docs/ each still fire it; modifying a document fires nothing; an unknown word warns and fires on everything; an empty or absent field fires on everything. closes #20 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:50:21 -05:00
# `Governs:` says *where* a document is authoritative; `Fires on:` says which
# kinds of change to that place its `Review trigger` actually names. The two come
# apart badly at the extreme: `DOC_TRUST_MAP.md` governs `docs/**`, the broadest
# glob in the tree, while its trigger is one of the narrowest — *any doc added,
# deleted or moved*. Matching on the glob alone fires it on every edit to every
# document forever, and a prompt that always fires is one people stop reading,
# which takes the true positives with it.
#
# Why a declared field rather than reading the trigger prose. The obvious first
# cut — look for `added`/`deleted`/`moved` and no `changed`/`change to` — was
# tried against the seven path-governing documents here and misclassified the one
# it exists to fix. `DOC_TRUST_MAP.md`'s trigger ends "any change to which doc
# owns a subject", so it reads as a change-verb; the clause is about which
# document owns a subject, not about a file being edited. Nothing lexical
# separates it from `architecture/README.md`'s "any change to a module boundary
# or a data shape", which genuinely does mean modification. Guessing at English
# and getting it wrong here is silent in the expensive direction: the document
# stops being prompted for and goes quietly stale.
#
# So the narrowing is declared or it does not happen. Absent, unparseable, or
# empty means fire on everything, which is the old behaviour — a document is only
# ever quietened by someone writing the line deliberately.
KIND_LETTERS = {
"added": {"A", "C"},
"deleted": {"D"},
"moved": {"R"},
"changed": {"M", "T"},
}
ALL_KINDS = {letter for letters in KIND_LETTERS.values() for letter in letters}
KIND_OF = {letter: word for word, letters in KIND_LETTERS.items() for letter in letters}
def fires_on(header: dict[str, str]) -> tuple[set[str], list[str]]:
"""The status letters a document accepts, and any words not understood.
Returns every letter when nothing is declared or the declaration cannot be
read, so the failure mode of a typo is a document that is prompted for too
often rather than one that is silently dropped.
"""
raw = header.get("Fires on", "").strip()
if not raw:
return ALL_KINDS, []
words = [w.strip().lower().rstrip(".") for w in re.split(r"[,;]|\band\b", raw)]
words = [w for w in words if w]
letters: set[str] = set()
unknown: list[str] = []
for word in words:
if word in KIND_LETTERS:
letters |= KIND_LETTERS[word]
else:
unknown.append(word)
if unknown or not letters:
return ALL_KINDS, unknown or ["(empty)"]
return letters, []
def _git(*args: str) -> str:
result = subprocess.run(
["git", *args], cwd=ROOT, capture_output=True, text=True, check=False
)
return result.stdout
fix(tools): doc-triggers matched the glob but not the trigger's verb DOC_TRUST_MAP.md declares `Governs: docs/**`, the broadest glob in the tree, while its Review trigger is one of the narrowest -- any doc added, deleted or moved. Matching on the glob alone fired it on every edit to every document, forever, and correctly by the only rule the tool had. Touching one script fired three documents and exactly one of them applied. A prompt that always fires is one people stop reading, and it takes the true positives with it. This tool exits 0 by design -- it is a prompt, not a gate -- which makes it more vulnerable to that, not less, because nothing forces the reading. Documents now declare the kinds of change their trigger names, in an optional `Fires on:` header field, read against git's own status letter. Absent, empty or unparseable means every kind, so nothing changes for the six other path-governing documents and a document is only ever quietened by somebody writing the line deliberately. ## Why declared rather than read out of the trigger prose The obvious first cut is to look for added/deleted/moved with no changed/change to. Tried against the seven path-governing documents here, it misclassifies the one it exists to fix: DOC_TRUST_MAP.md's trigger ends "any change to which doc owns a subject", so it reads as a change-verb. That clause is about which document owns a subject, not about a file being edited, and nothing lexical separates it from architecture/README.md's "any change to a module boundary or a data shape", which genuinely does mean modification. Guessing at English is silent in the expensive direction: a document wrongly read as existence-only stops being prompted for and goes quietly stale, which is the failure this whole tool exists to prevent. So the narrowing is declared or it does not happen. ## Also changed_paths now carries a status letter per path, from --name-status for --staged and --range and from the porcelain columns for the working tree. Paths named on the command line have no diff to read, so the kind is inferred: absent from disk is a deletion, present but untracked is an addition, otherwise a modification. Documents that govern a path in the change but do not fire on its kind are named in their own short block rather than dropped, because a reader who saw nothing would have to guess whether they had been considered. The no-match message now distinguishes "nothing governs these paths" from "governed, but not this kind of change" -- the second is a declaration somebody wrote, not an unclaimed area. Verified: modifying a script fires TOOLS.md and architecture/README.md and not DOC_TRUST_MAP.md; adding, deleting and moving a document under docs/ each still fire it; modifying a document fires nothing; an unknown word warns and fires on everything; an empty or absent field fires on everything. closes #20 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:50:21 -05:00
def _name_status(*args: str) -> list[tuple[str, str]]:
"""(letter, path) from a `--name-status` listing.
A rename arrives as `R100<TAB>old<TAB>new`, so the path is taken from the
last field: the new name governs, as it did when only names were read.
"""
pairs = []
for line in _git(*args, "--name-status").splitlines():
if not line.strip():
continue
fields = line.split("\t")
if len(fields) < 2 or not fields[0].strip():
continue
pairs.append((fields[0].strip()[0].upper(), fields[-1].strip()))
return pairs
def _status_of_named(path: str) -> str:
"""The kind of change a path named on the command line represents.
There is no diff to read here, so it is inferred: gone from disk is a
deletion, present but untracked is an addition, and anything else is a
modification the usual reason to ask about a path by name.
"""
if not (ROOT / path).exists():
return "D"
return "M" if _git("ls-files", "--", path).strip() else "A"
def changed_paths(argv: list[str]) -> tuple[list[tuple[str, str]], str]:
if argv and argv[0] == "--staged":
fix(tools): doc-triggers matched the glob but not the trigger's verb DOC_TRUST_MAP.md declares `Governs: docs/**`, the broadest glob in the tree, while its Review trigger is one of the narrowest -- any doc added, deleted or moved. Matching on the glob alone fired it on every edit to every document, forever, and correctly by the only rule the tool had. Touching one script fired three documents and exactly one of them applied. A prompt that always fires is one people stop reading, and it takes the true positives with it. This tool exits 0 by design -- it is a prompt, not a gate -- which makes it more vulnerable to that, not less, because nothing forces the reading. Documents now declare the kinds of change their trigger names, in an optional `Fires on:` header field, read against git's own status letter. Absent, empty or unparseable means every kind, so nothing changes for the six other path-governing documents and a document is only ever quietened by somebody writing the line deliberately. ## Why declared rather than read out of the trigger prose The obvious first cut is to look for added/deleted/moved with no changed/change to. Tried against the seven path-governing documents here, it misclassifies the one it exists to fix: DOC_TRUST_MAP.md's trigger ends "any change to which doc owns a subject", so it reads as a change-verb. That clause is about which document owns a subject, not about a file being edited, and nothing lexical separates it from architecture/README.md's "any change to a module boundary or a data shape", which genuinely does mean modification. Guessing at English is silent in the expensive direction: a document wrongly read as existence-only stops being prompted for and goes quietly stale, which is the failure this whole tool exists to prevent. So the narrowing is declared or it does not happen. ## Also changed_paths now carries a status letter per path, from --name-status for --staged and --range and from the porcelain columns for the working tree. Paths named on the command line have no diff to read, so the kind is inferred: absent from disk is a deletion, present but untracked is an addition, otherwise a modification. Documents that govern a path in the change but do not fire on its kind are named in their own short block rather than dropped, because a reader who saw nothing would have to guess whether they had been considered. The no-match message now distinguishes "nothing governs these paths" from "governed, but not this kind of change" -- the second is a declaration somebody wrote, not an unclaimed area. Verified: modifying a script fires TOOLS.md and architecture/README.md and not DOC_TRUST_MAP.md; adding, deleting and moving a document under docs/ each still fire it; modifying a document fires nothing; an unknown word warns and fires on everything; an empty or absent field fires on everything. closes #20 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:50:21 -05:00
return _name_status("diff", "--cached"), "staged"
if argv and argv[0] == "--range":
if len(argv) < 2:
sys.exit("doc-triggers: --range needs a revision range")
fix(tools): doc-triggers matched the glob but not the trigger's verb DOC_TRUST_MAP.md declares `Governs: docs/**`, the broadest glob in the tree, while its Review trigger is one of the narrowest -- any doc added, deleted or moved. Matching on the glob alone fired it on every edit to every document, forever, and correctly by the only rule the tool had. Touching one script fired three documents and exactly one of them applied. A prompt that always fires is one people stop reading, and it takes the true positives with it. This tool exits 0 by design -- it is a prompt, not a gate -- which makes it more vulnerable to that, not less, because nothing forces the reading. Documents now declare the kinds of change their trigger names, in an optional `Fires on:` header field, read against git's own status letter. Absent, empty or unparseable means every kind, so nothing changes for the six other path-governing documents and a document is only ever quietened by somebody writing the line deliberately. ## Why declared rather than read out of the trigger prose The obvious first cut is to look for added/deleted/moved with no changed/change to. Tried against the seven path-governing documents here, it misclassifies the one it exists to fix: DOC_TRUST_MAP.md's trigger ends "any change to which doc owns a subject", so it reads as a change-verb. That clause is about which document owns a subject, not about a file being edited, and nothing lexical separates it from architecture/README.md's "any change to a module boundary or a data shape", which genuinely does mean modification. Guessing at English is silent in the expensive direction: a document wrongly read as existence-only stops being prompted for and goes quietly stale, which is the failure this whole tool exists to prevent. So the narrowing is declared or it does not happen. ## Also changed_paths now carries a status letter per path, from --name-status for --staged and --range and from the porcelain columns for the working tree. Paths named on the command line have no diff to read, so the kind is inferred: absent from disk is a deletion, present but untracked is an addition, otherwise a modification. Documents that govern a path in the change but do not fire on its kind are named in their own short block rather than dropped, because a reader who saw nothing would have to guess whether they had been considered. The no-match message now distinguishes "nothing governs these paths" from "governed, but not this kind of change" -- the second is a declaration somebody wrote, not an unclaimed area. Verified: modifying a script fires TOOLS.md and architecture/README.md and not DOC_TRUST_MAP.md; adding, deleting and moving a document under docs/ each still fire it; modifying a document fires nothing; an unknown word warns and fires on everything; an empty or absent field fires on everything. closes #20 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:50:21 -05:00
return _name_status("diff", argv[1]), f"range {argv[1]}"
if argv:
fix(tools): doc-triggers matched the glob but not the trigger's verb DOC_TRUST_MAP.md declares `Governs: docs/**`, the broadest glob in the tree, while its Review trigger is one of the narrowest -- any doc added, deleted or moved. Matching on the glob alone fired it on every edit to every document, forever, and correctly by the only rule the tool had. Touching one script fired three documents and exactly one of them applied. A prompt that always fires is one people stop reading, and it takes the true positives with it. This tool exits 0 by design -- it is a prompt, not a gate -- which makes it more vulnerable to that, not less, because nothing forces the reading. Documents now declare the kinds of change their trigger names, in an optional `Fires on:` header field, read against git's own status letter. Absent, empty or unparseable means every kind, so nothing changes for the six other path-governing documents and a document is only ever quietened by somebody writing the line deliberately. ## Why declared rather than read out of the trigger prose The obvious first cut is to look for added/deleted/moved with no changed/change to. Tried against the seven path-governing documents here, it misclassifies the one it exists to fix: DOC_TRUST_MAP.md's trigger ends "any change to which doc owns a subject", so it reads as a change-verb. That clause is about which document owns a subject, not about a file being edited, and nothing lexical separates it from architecture/README.md's "any change to a module boundary or a data shape", which genuinely does mean modification. Guessing at English is silent in the expensive direction: a document wrongly read as existence-only stops being prompted for and goes quietly stale, which is the failure this whole tool exists to prevent. So the narrowing is declared or it does not happen. ## Also changed_paths now carries a status letter per path, from --name-status for --staged and --range and from the porcelain columns for the working tree. Paths named on the command line have no diff to read, so the kind is inferred: absent from disk is a deletion, present but untracked is an addition, otherwise a modification. Documents that govern a path in the change but do not fire on its kind are named in their own short block rather than dropped, because a reader who saw nothing would have to guess whether they had been considered. The no-match message now distinguishes "nothing governs these paths" from "governed, but not this kind of change" -- the second is a declaration somebody wrote, not an unclaimed area. Verified: modifying a script fires TOOLS.md and architecture/README.md and not DOC_TRUST_MAP.md; adding, deleting and moving a document under docs/ each still fire it; modifying a document fires nothing; an unknown word warns and fires on everything; an empty or absent field fires on everything. closes #20 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:50:21 -05:00
return [(_status_of_named(a), a) for a in argv], "named paths"
# Untracked files are included on purpose: a brand-new module is the case
# most likely to need a document and least likely to be remembered, and it
# is invisible to `git diff`.
fix(tools): doc-triggers matched the glob but not the trigger's verb DOC_TRUST_MAP.md declares `Governs: docs/**`, the broadest glob in the tree, while its Review trigger is one of the narrowest -- any doc added, deleted or moved. Matching on the glob alone fired it on every edit to every document, forever, and correctly by the only rule the tool had. Touching one script fired three documents and exactly one of them applied. A prompt that always fires is one people stop reading, and it takes the true positives with it. This tool exits 0 by design -- it is a prompt, not a gate -- which makes it more vulnerable to that, not less, because nothing forces the reading. Documents now declare the kinds of change their trigger names, in an optional `Fires on:` header field, read against git's own status letter. Absent, empty or unparseable means every kind, so nothing changes for the six other path-governing documents and a document is only ever quietened by somebody writing the line deliberately. ## Why declared rather than read out of the trigger prose The obvious first cut is to look for added/deleted/moved with no changed/change to. Tried against the seven path-governing documents here, it misclassifies the one it exists to fix: DOC_TRUST_MAP.md's trigger ends "any change to which doc owns a subject", so it reads as a change-verb. That clause is about which document owns a subject, not about a file being edited, and nothing lexical separates it from architecture/README.md's "any change to a module boundary or a data shape", which genuinely does mean modification. Guessing at English is silent in the expensive direction: a document wrongly read as existence-only stops being prompted for and goes quietly stale, which is the failure this whole tool exists to prevent. So the narrowing is declared or it does not happen. ## Also changed_paths now carries a status letter per path, from --name-status for --staged and --range and from the porcelain columns for the working tree. Paths named on the command line have no diff to read, so the kind is inferred: absent from disk is a deletion, present but untracked is an addition, otherwise a modification. Documents that govern a path in the change but do not fire on its kind are named in their own short block rather than dropped, because a reader who saw nothing would have to guess whether they had been considered. The no-match message now distinguishes "nothing governs these paths" from "governed, but not this kind of change" -- the second is a declaration somebody wrote, not an unclaimed area. Verified: modifying a script fires TOOLS.md and architecture/README.md and not DOC_TRUST_MAP.md; adding, deleting and moving a document under docs/ each still fire it; modifying a document fires nothing; an unknown word warns and fires on everything; an empty or absent field fires on everything. closes #20 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:50:21 -05:00
pairs = []
for line in _git("status", "--porcelain").splitlines():
if not line.strip():
continue
fix(tools): doc-triggers matched the glob but not the trigger's verb DOC_TRUST_MAP.md declares `Governs: docs/**`, the broadest glob in the tree, while its Review trigger is one of the narrowest -- any doc added, deleted or moved. Matching on the glob alone fired it on every edit to every document, forever, and correctly by the only rule the tool had. Touching one script fired three documents and exactly one of them applied. A prompt that always fires is one people stop reading, and it takes the true positives with it. This tool exits 0 by design -- it is a prompt, not a gate -- which makes it more vulnerable to that, not less, because nothing forces the reading. Documents now declare the kinds of change their trigger names, in an optional `Fires on:` header field, read against git's own status letter. Absent, empty or unparseable means every kind, so nothing changes for the six other path-governing documents and a document is only ever quietened by somebody writing the line deliberately. ## Why declared rather than read out of the trigger prose The obvious first cut is to look for added/deleted/moved with no changed/change to. Tried against the seven path-governing documents here, it misclassifies the one it exists to fix: DOC_TRUST_MAP.md's trigger ends "any change to which doc owns a subject", so it reads as a change-verb. That clause is about which document owns a subject, not about a file being edited, and nothing lexical separates it from architecture/README.md's "any change to a module boundary or a data shape", which genuinely does mean modification. Guessing at English is silent in the expensive direction: a document wrongly read as existence-only stops being prompted for and goes quietly stale, which is the failure this whole tool exists to prevent. So the narrowing is declared or it does not happen. ## Also changed_paths now carries a status letter per path, from --name-status for --staged and --range and from the porcelain columns for the working tree. Paths named on the command line have no diff to read, so the kind is inferred: absent from disk is a deletion, present but untracked is an addition, otherwise a modification. Documents that govern a path in the change but do not fire on its kind are named in their own short block rather than dropped, because a reader who saw nothing would have to guess whether they had been considered. The no-match message now distinguishes "nothing governs these paths" from "governed, but not this kind of change" -- the second is a declaration somebody wrote, not an unclaimed area. Verified: modifying a script fires TOOLS.md and architecture/README.md and not DOC_TRUST_MAP.md; adding, deleting and moving a document under docs/ each still fire it; modifying a document fires nothing; an unknown word warns and fires on everything; an empty or absent field fires on everything. closes #20 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:50:21 -05:00
index, worktree = line[0], line[1]
path = line[3:].strip()
if " -> " in path: # a rename; the new name governs
path = path.split(" -> ", 1)[1]
fix(tools): doc-triggers matched the glob but not the trigger's verb DOC_TRUST_MAP.md declares `Governs: docs/**`, the broadest glob in the tree, while its Review trigger is one of the narrowest -- any doc added, deleted or moved. Matching on the glob alone fired it on every edit to every document, forever, and correctly by the only rule the tool had. Touching one script fired three documents and exactly one of them applied. A prompt that always fires is one people stop reading, and it takes the true positives with it. This tool exits 0 by design -- it is a prompt, not a gate -- which makes it more vulnerable to that, not less, because nothing forces the reading. Documents now declare the kinds of change their trigger names, in an optional `Fires on:` header field, read against git's own status letter. Absent, empty or unparseable means every kind, so nothing changes for the six other path-governing documents and a document is only ever quietened by somebody writing the line deliberately. ## Why declared rather than read out of the trigger prose The obvious first cut is to look for added/deleted/moved with no changed/change to. Tried against the seven path-governing documents here, it misclassifies the one it exists to fix: DOC_TRUST_MAP.md's trigger ends "any change to which doc owns a subject", so it reads as a change-verb. That clause is about which document owns a subject, not about a file being edited, and nothing lexical separates it from architecture/README.md's "any change to a module boundary or a data shape", which genuinely does mean modification. Guessing at English is silent in the expensive direction: a document wrongly read as existence-only stops being prompted for and goes quietly stale, which is the failure this whole tool exists to prevent. So the narrowing is declared or it does not happen. ## Also changed_paths now carries a status letter per path, from --name-status for --staged and --range and from the porcelain columns for the working tree. Paths named on the command line have no diff to read, so the kind is inferred: absent from disk is a deletion, present but untracked is an addition, otherwise a modification. Documents that govern a path in the change but do not fire on its kind are named in their own short block rather than dropped, because a reader who saw nothing would have to guess whether they had been considered. The no-match message now distinguishes "nothing governs these paths" from "governed, but not this kind of change" -- the second is a declaration somebody wrote, not an unclaimed area. Verified: modifying a script fires TOOLS.md and architecture/README.md and not DOC_TRUST_MAP.md; adding, deleting and moving a document under docs/ each still fire it; modifying a document fires nothing; an unknown word warns and fires on everything; an empty or absent field fires on everything. closes #20 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:50:21 -05:00
if "?" in (index, worktree):
letter = "A" # untracked: a file that is new
else:
letter = (index if index != " " else worktree).upper()
pairs.append((letter, path.strip('"')))
return pairs, "working tree"
def main() -> int:
if not DOCS.is_dir():
print(f"doc-triggers: no docs/ directory at {DOCS}")
return 0
paths, source = changed_paths(sys.argv[1:])
if not paths:
print(f"doc-triggers: nothing changed in the {source}.")
return 0
print(f"doc-triggers: {len(paths)} path(s) from the {source}\n")
fix(tools): doc-triggers matched the glob but not the trigger's verb DOC_TRUST_MAP.md declares `Governs: docs/**`, the broadest glob in the tree, while its Review trigger is one of the narrowest -- any doc added, deleted or moved. Matching on the glob alone fired it on every edit to every document, forever, and correctly by the only rule the tool had. Touching one script fired three documents and exactly one of them applied. A prompt that always fires is one people stop reading, and it takes the true positives with it. This tool exits 0 by design -- it is a prompt, not a gate -- which makes it more vulnerable to that, not less, because nothing forces the reading. Documents now declare the kinds of change their trigger names, in an optional `Fires on:` header field, read against git's own status letter. Absent, empty or unparseable means every kind, so nothing changes for the six other path-governing documents and a document is only ever quietened by somebody writing the line deliberately. ## Why declared rather than read out of the trigger prose The obvious first cut is to look for added/deleted/moved with no changed/change to. Tried against the seven path-governing documents here, it misclassifies the one it exists to fix: DOC_TRUST_MAP.md's trigger ends "any change to which doc owns a subject", so it reads as a change-verb. That clause is about which document owns a subject, not about a file being edited, and nothing lexical separates it from architecture/README.md's "any change to a module boundary or a data shape", which genuinely does mean modification. Guessing at English is silent in the expensive direction: a document wrongly read as existence-only stops being prompted for and goes quietly stale, which is the failure this whole tool exists to prevent. So the narrowing is declared or it does not happen. ## Also changed_paths now carries a status letter per path, from --name-status for --staged and --range and from the porcelain columns for the working tree. Paths named on the command line have no diff to read, so the kind is inferred: absent from disk is a deletion, present but untracked is an addition, otherwise a modification. Documents that govern a path in the change but do not fire on its kind are named in their own short block rather than dropped, because a reader who saw nothing would have to guess whether they had been considered. The no-match message now distinguishes "nothing governs these paths" from "governed, but not this kind of change" -- the second is a declaration somebody wrote, not an unclaimed area. Verified: modifying a script fires TOOLS.md and architecture/README.md and not DOC_TRUST_MAP.md; adding, deleting and moving a document under docs/ each still fire it; modifying a document fires nothing; an unknown word warns and fires on everything; an empty or absent field fires on everything. closes #20 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:50:21 -05:00
fired: list[tuple[str, list[tuple[str, str]], str]] = []
subject_only: list[str] = []
fix(tools): doc-triggers matched the glob but not the trigger's verb DOC_TRUST_MAP.md declares `Governs: docs/**`, the broadest glob in the tree, while its Review trigger is one of the narrowest -- any doc added, deleted or moved. Matching on the glob alone fired it on every edit to every document, forever, and correctly by the only rule the tool had. Touching one script fired three documents and exactly one of them applied. A prompt that always fires is one people stop reading, and it takes the true positives with it. This tool exits 0 by design -- it is a prompt, not a gate -- which makes it more vulnerable to that, not less, because nothing forces the reading. Documents now declare the kinds of change their trigger names, in an optional `Fires on:` header field, read against git's own status letter. Absent, empty or unparseable means every kind, so nothing changes for the six other path-governing documents and a document is only ever quietened by somebody writing the line deliberately. ## Why declared rather than read out of the trigger prose The obvious first cut is to look for added/deleted/moved with no changed/change to. Tried against the seven path-governing documents here, it misclassifies the one it exists to fix: DOC_TRUST_MAP.md's trigger ends "any change to which doc owns a subject", so it reads as a change-verb. That clause is about which document owns a subject, not about a file being edited, and nothing lexical separates it from architecture/README.md's "any change to a module boundary or a data shape", which genuinely does mean modification. Guessing at English is silent in the expensive direction: a document wrongly read as existence-only stops being prompted for and goes quietly stale, which is the failure this whole tool exists to prevent. So the narrowing is declared or it does not happen. ## Also changed_paths now carries a status letter per path, from --name-status for --staged and --range and from the porcelain columns for the working tree. Paths named on the command line have no diff to read, so the kind is inferred: absent from disk is a deletion, present but untracked is an addition, otherwise a modification. Documents that govern a path in the change but do not fire on its kind are named in their own short block rather than dropped, because a reader who saw nothing would have to guess whether they had been considered. The no-match message now distinguishes "nothing governs these paths" from "governed, but not this kind of change" -- the second is a declaration somebody wrote, not an unclaimed area. Verified: modifying a script fires TOOLS.md and architecture/README.md and not DOC_TRUST_MAP.md; adding, deleting and moving a document under docs/ each still fire it; modifying a document fires nothing; an unknown word warns and fires on everything; an empty or absent field fires on everything. closes #20 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:50:21 -05:00
wrong_kind: list[tuple[str, str]] = []
for doc in sorted(DOCS.rglob("*.md")):
rel = str(doc.relative_to(ROOT))
header = header_of(doc)
governs = header.get("Governs", "")
if not governs:
continue
globs = [g.strip() for g in governs.split(",") if g.strip()]
path_globs = [g for g in globs if looks_like_path(g)]
if not path_globs:
subject_only.append(rel)
continue
fix(tools): doc-triggers matched the glob but not the trigger's verb DOC_TRUST_MAP.md declares `Governs: docs/**`, the broadest glob in the tree, while its Review trigger is one of the narrowest -- any doc added, deleted or moved. Matching on the glob alone fired it on every edit to every document, forever, and correctly by the only rule the tool had. Touching one script fired three documents and exactly one of them applied. A prompt that always fires is one people stop reading, and it takes the true positives with it. This tool exits 0 by design -- it is a prompt, not a gate -- which makes it more vulnerable to that, not less, because nothing forces the reading. Documents now declare the kinds of change their trigger names, in an optional `Fires on:` header field, read against git's own status letter. Absent, empty or unparseable means every kind, so nothing changes for the six other path-governing documents and a document is only ever quietened by somebody writing the line deliberately. ## Why declared rather than read out of the trigger prose The obvious first cut is to look for added/deleted/moved with no changed/change to. Tried against the seven path-governing documents here, it misclassifies the one it exists to fix: DOC_TRUST_MAP.md's trigger ends "any change to which doc owns a subject", so it reads as a change-verb. That clause is about which document owns a subject, not about a file being edited, and nothing lexical separates it from architecture/README.md's "any change to a module boundary or a data shape", which genuinely does mean modification. Guessing at English is silent in the expensive direction: a document wrongly read as existence-only stops being prompted for and goes quietly stale, which is the failure this whole tool exists to prevent. So the narrowing is declared or it does not happen. ## Also changed_paths now carries a status letter per path, from --name-status for --staged and --range and from the porcelain columns for the working tree. Paths named on the command line have no diff to read, so the kind is inferred: absent from disk is a deletion, present but untracked is an addition, otherwise a modification. Documents that govern a path in the change but do not fire on its kind are named in their own short block rather than dropped, because a reader who saw nothing would have to guess whether they had been considered. The no-match message now distinguishes "nothing governs these paths" from "governed, but not this kind of change" -- the second is a declaration somebody wrote, not an unclaimed area. Verified: modifying a script fires TOOLS.md and architecture/README.md and not DOC_TRUST_MAP.md; adding, deleting and moving a document under docs/ each still fire it; modifying a document fires nothing; an unknown word warns and fires on everything; an empty or absent field fires on everything. closes #20 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:50:21 -05:00
letters, unknown = fires_on(header)
if unknown:
print(
f"doc-triggers: {rel} declares 'Fires on: "
f"{header.get('Fires on', '')}'{', '.join(unknown)} not "
f"understood, so it fires on everything.\n"
)
matched = {(s, p) for s, p in paths for g in path_globs if matches(p, g)}
hits = sorted({(s, p) for s, p in matched if s in letters}, key=lambda x: x[1])
if hits:
fired.append((rel, hits, header.get("Review trigger", "(none stated)")))
fix(tools): doc-triggers matched the glob but not the trigger's verb DOC_TRUST_MAP.md declares `Governs: docs/**`, the broadest glob in the tree, while its Review trigger is one of the narrowest -- any doc added, deleted or moved. Matching on the glob alone fired it on every edit to every document, forever, and correctly by the only rule the tool had. Touching one script fired three documents and exactly one of them applied. A prompt that always fires is one people stop reading, and it takes the true positives with it. This tool exits 0 by design -- it is a prompt, not a gate -- which makes it more vulnerable to that, not less, because nothing forces the reading. Documents now declare the kinds of change their trigger names, in an optional `Fires on:` header field, read against git's own status letter. Absent, empty or unparseable means every kind, so nothing changes for the six other path-governing documents and a document is only ever quietened by somebody writing the line deliberately. ## Why declared rather than read out of the trigger prose The obvious first cut is to look for added/deleted/moved with no changed/change to. Tried against the seven path-governing documents here, it misclassifies the one it exists to fix: DOC_TRUST_MAP.md's trigger ends "any change to which doc owns a subject", so it reads as a change-verb. That clause is about which document owns a subject, not about a file being edited, and nothing lexical separates it from architecture/README.md's "any change to a module boundary or a data shape", which genuinely does mean modification. Guessing at English is silent in the expensive direction: a document wrongly read as existence-only stops being prompted for and goes quietly stale, which is the failure this whole tool exists to prevent. So the narrowing is declared or it does not happen. ## Also changed_paths now carries a status letter per path, from --name-status for --staged and --range and from the porcelain columns for the working tree. Paths named on the command line have no diff to read, so the kind is inferred: absent from disk is a deletion, present but untracked is an addition, otherwise a modification. Documents that govern a path in the change but do not fire on its kind are named in their own short block rather than dropped, because a reader who saw nothing would have to guess whether they had been considered. The no-match message now distinguishes "nothing governs these paths" from "governed, but not this kind of change" -- the second is a declaration somebody wrote, not an unclaimed area. Verified: modifying a script fires TOOLS.md and architecture/README.md and not DOC_TRUST_MAP.md; adding, deleting and moving a document under docs/ each still fire it; modifying a document fires nothing; an unknown word warns and fires on everything; an empty or absent field fires on everything. closes #20 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:50:21 -05:00
elif matched:
# Governed, and deliberately not prompted for: the paths changed in a
# way this document's trigger does not name. Said out loud, because a
# reader who saw nothing would have to guess whether it was checked.
wrong_kind.append((rel, header.get("Fires on", "").strip()))
for rel, hits, trigger in fired:
print(f"\033[1m{rel}\033[0m")
fix(tools): doc-triggers matched the glob but not the trigger's verb DOC_TRUST_MAP.md declares `Governs: docs/**`, the broadest glob in the tree, while its Review trigger is one of the narrowest -- any doc added, deleted or moved. Matching on the glob alone fired it on every edit to every document, forever, and correctly by the only rule the tool had. Touching one script fired three documents and exactly one of them applied. A prompt that always fires is one people stop reading, and it takes the true positives with it. This tool exits 0 by design -- it is a prompt, not a gate -- which makes it more vulnerable to that, not less, because nothing forces the reading. Documents now declare the kinds of change their trigger names, in an optional `Fires on:` header field, read against git's own status letter. Absent, empty or unparseable means every kind, so nothing changes for the six other path-governing documents and a document is only ever quietened by somebody writing the line deliberately. ## Why declared rather than read out of the trigger prose The obvious first cut is to look for added/deleted/moved with no changed/change to. Tried against the seven path-governing documents here, it misclassifies the one it exists to fix: DOC_TRUST_MAP.md's trigger ends "any change to which doc owns a subject", so it reads as a change-verb. That clause is about which document owns a subject, not about a file being edited, and nothing lexical separates it from architecture/README.md's "any change to a module boundary or a data shape", which genuinely does mean modification. Guessing at English is silent in the expensive direction: a document wrongly read as existence-only stops being prompted for and goes quietly stale, which is the failure this whole tool exists to prevent. So the narrowing is declared or it does not happen. ## Also changed_paths now carries a status letter per path, from --name-status for --staged and --range and from the porcelain columns for the working tree. Paths named on the command line have no diff to read, so the kind is inferred: absent from disk is a deletion, present but untracked is an addition, otherwise a modification. Documents that govern a path in the change but do not fire on its kind are named in their own short block rather than dropped, because a reader who saw nothing would have to guess whether they had been considered. The no-match message now distinguishes "nothing governs these paths" from "governed, but not this kind of change" -- the second is a declaration somebody wrote, not an unclaimed area. Verified: modifying a script fires TOOLS.md and architecture/README.md and not DOC_TRUST_MAP.md; adding, deleting and moving a document under docs/ each still fire it; modifying a document fires nothing; an unknown word warns and fires on everything; an empty or absent field fires on everything. closes #20 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:50:21 -05:00
for letter, hit in hits[:6]:
print(f" {KIND_OF.get(letter, letter.lower()):>7} {hit}")
if len(hits) > 6:
print(f" … and {len(hits) - 6} more")
print(f" trigger: {trigger}\n")
if fired:
print(f"{len(fired)} document(s) govern something in this change.")
print("Read each trigger and decide — the rule is to update them in the")
print("SAME commit as the code, not afterwards.")
fix(tools): doc-triggers matched the glob but not the trigger's verb DOC_TRUST_MAP.md declares `Governs: docs/**`, the broadest glob in the tree, while its Review trigger is one of the narrowest -- any doc added, deleted or moved. Matching on the glob alone fired it on every edit to every document, forever, and correctly by the only rule the tool had. Touching one script fired three documents and exactly one of them applied. A prompt that always fires is one people stop reading, and it takes the true positives with it. This tool exits 0 by design -- it is a prompt, not a gate -- which makes it more vulnerable to that, not less, because nothing forces the reading. Documents now declare the kinds of change their trigger names, in an optional `Fires on:` header field, read against git's own status letter. Absent, empty or unparseable means every kind, so nothing changes for the six other path-governing documents and a document is only ever quietened by somebody writing the line deliberately. ## Why declared rather than read out of the trigger prose The obvious first cut is to look for added/deleted/moved with no changed/change to. Tried against the seven path-governing documents here, it misclassifies the one it exists to fix: DOC_TRUST_MAP.md's trigger ends "any change to which doc owns a subject", so it reads as a change-verb. That clause is about which document owns a subject, not about a file being edited, and nothing lexical separates it from architecture/README.md's "any change to a module boundary or a data shape", which genuinely does mean modification. Guessing at English is silent in the expensive direction: a document wrongly read as existence-only stops being prompted for and goes quietly stale, which is the failure this whole tool exists to prevent. So the narrowing is declared or it does not happen. ## Also changed_paths now carries a status letter per path, from --name-status for --staged and --range and from the porcelain columns for the working tree. Paths named on the command line have no diff to read, so the kind is inferred: absent from disk is a deletion, present but untracked is an addition, otherwise a modification. Documents that govern a path in the change but do not fire on its kind are named in their own short block rather than dropped, because a reader who saw nothing would have to guess whether they had been considered. The no-match message now distinguishes "nothing governs these paths" from "governed, but not this kind of change" -- the second is a declaration somebody wrote, not an unclaimed area. Verified: modifying a script fires TOOLS.md and architecture/README.md and not DOC_TRUST_MAP.md; adding, deleting and moving a document under docs/ each still fire it; modifying a document fires nothing; an unknown word warns and fires on everything; an empty or absent field fires on everything. closes #20 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:50:21 -05:00
elif wrong_kind:
# Distinct from matching nothing, and worth separating: a path here *is*
# governed, and the reason nothing fired is a declaration somebody wrote,
# not an area no document claims.
print("Nothing fired. The paths in this change are governed, but only by")
print("documents whose Fires on declaration excludes this kind of change.")
else:
print("No document's Governs matched these paths. Worth a second look if")
print("this change added a module, a migration, or a new boundary — an")
print("unmatched path can also mean no document claims that area yet.")
fix(tools): doc-triggers matched the glob but not the trigger's verb DOC_TRUST_MAP.md declares `Governs: docs/**`, the broadest glob in the tree, while its Review trigger is one of the narrowest -- any doc added, deleted or moved. Matching on the glob alone fired it on every edit to every document, forever, and correctly by the only rule the tool had. Touching one script fired three documents and exactly one of them applied. A prompt that always fires is one people stop reading, and it takes the true positives with it. This tool exits 0 by design -- it is a prompt, not a gate -- which makes it more vulnerable to that, not less, because nothing forces the reading. Documents now declare the kinds of change their trigger names, in an optional `Fires on:` header field, read against git's own status letter. Absent, empty or unparseable means every kind, so nothing changes for the six other path-governing documents and a document is only ever quietened by somebody writing the line deliberately. ## Why declared rather than read out of the trigger prose The obvious first cut is to look for added/deleted/moved with no changed/change to. Tried against the seven path-governing documents here, it misclassifies the one it exists to fix: DOC_TRUST_MAP.md's trigger ends "any change to which doc owns a subject", so it reads as a change-verb. That clause is about which document owns a subject, not about a file being edited, and nothing lexical separates it from architecture/README.md's "any change to a module boundary or a data shape", which genuinely does mean modification. Guessing at English is silent in the expensive direction: a document wrongly read as existence-only stops being prompted for and goes quietly stale, which is the failure this whole tool exists to prevent. So the narrowing is declared or it does not happen. ## Also changed_paths now carries a status letter per path, from --name-status for --staged and --range and from the porcelain columns for the working tree. Paths named on the command line have no diff to read, so the kind is inferred: absent from disk is a deletion, present but untracked is an addition, otherwise a modification. Documents that govern a path in the change but do not fire on its kind are named in their own short block rather than dropped, because a reader who saw nothing would have to guess whether they had been considered. The no-match message now distinguishes "nothing governs these paths" from "governed, but not this kind of change" -- the second is a declaration somebody wrote, not an unclaimed area. Verified: modifying a script fires TOOLS.md and architecture/README.md and not DOC_TRUST_MAP.md; adding, deleting and moving a document under docs/ each still fire it; modifying a document fires nothing; an unknown word warns and fires on everything; an empty or absent field fires on everything. closes #20 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:50:21 -05:00
if wrong_kind:
print("\nGovern a path in this change but do not fire on this kind of")
print("change, by their own Fires on declaration:")
for rel, declared in wrong_kind:
print(f" {rel} — fires on {declared}")
if subject_only:
print("\nNot checked here — these govern a subject rather than paths, so")
print("no change can fire them mechanically. Judge them yourself:")
for rel in subject_only:
print(f" {rel}")
return 0
if __name__ == "__main__":
raise SystemExit(main())