diff --git a/PENDING.md b/PENDING.md index 0194051..e3b79c6 100644 --- a/PENDING.md +++ b/PENDING.md @@ -6079,3 +6079,34 @@ The item presented (c) as *keep the backup* **or** *remove the vector*. The juri **Recommendation, revised: corrected-(d) first, then (b) once (d) is live, and (c) available on the archive route whenever the steward wants the vector gone.** (c) is no longer gated on retiring the backup. **Awaiting:** Steward authorization. Nothing here is built; this corrects a specification before it is implemented, which is the cheapest point at which this correction could have landed. + +### PENDING-164 — BUILD RECORD + AMENDMENT 1 result: (c) and (d) built; the census's second half is not mechanical +**Date:** 2026-08-26 +**Authorization:** steward, in session — *"Pending 164 c + d authorized with the amendment."* + +**(c) and (d) are built, as ONE implementation with two surfaces.** `scripts/prior-art.py` searches commit messages across every owned repo with **no count window**, and reports the register's mention count beside it. The executor runs it as a CLI (**(d)**, the pre-proposal check); `governance-mcp.py` exposes it as `prior_art` (**(c)**, the jurist's reach). One implementation deliberately — a value computed twice on two sides of a boundary is how the two parties end up with different answers. + +**The finding it looks for is the ASYMMETRY**, not the hits: commits mention it, the register does not. That is a decision taken in a repo and never routed into the record. + +**Verified against the case that motivated the item.** Run on `LFS` it returns **20 commits including `0677e8a` and `95760ff`** — both beyond `repo_activity`'s 100-commit floor, one of them in `dotfiles`, which is not in `REPOS` at all. ⚠ **The positive control forced the enumeration**: had owned repos been copied from `REPOS`, `95760ff` would have been unreachable and the control would have failed. Repos are now **computed from remote ownership**, not hand-listed. + +⚠ **Had this existed this morning, one command before filing PENDING-163 would have returned `0677e8a` and `400c054`. The entire afternoon's detour was one command away.** + +**⚠ The instrument's first run returned zero, and the control caught it.** `sh()` discarded stdout on a non-zero exit; `find` over `$HOME` exits 1 because 154 directories under `Library` are unreadable — **while printing all 37 repos to stdout.** Every search returned nothing. Without the control that is a clean, confident *"no prior art"* for every term ever queried. **This is the third false-zero of the day and the first one a control caught before it was believed** — the difference from the census's zsh zero is entirely that the jurist pre-specified what the instrument must return. + +**AMENDMENT 1's census: the mechanical half runs; the interpretive half does not, and that IS the result.** + +| verb set | candidates | +|---|---| +| first, incl. "remove"/"replace with"/"no longer" | **661** | +| narrowed to strong retirement/adoption forms | **270** (243 outside `dotfiles`) | + +Neither number is a census result — both are haystacks. The specification has two halves: grep the verbs, **then check each hit against the register**. The first is mechanical. The second requires reading each commit to identify *which mechanism it decided about*, and that is interpretation, not extraction. **The same shape as PENDING-151 step 1, where propositions could not be extracted mechanically either — and, as there, the honest move is to declare the limit rather than manufacture the column.** + +**⇒ The backlog is NOT censused, and no number here should be read as one.** What exists is a candidate list of 270, unclassified. Classifying it is a reading task of a few hours, and it is proposed as such rather than smuggled in as a result. **The forward-looking halves — (c) and (d) — are complete and need nothing further.** + +**Files:** `scripts/prior-art.py` (new), `scripts/governance-mcp.py` (+`prior_art`, +9 controls). + +⚠ **The read-only guarantee was extended, and doing so found a pre-existing hole.** `governance-mcp.py` proves read-only-ness by AST **over itself**; adding a delegate put code outside that proof. Extending it to delegates surfaced (a) a **false-positive class** — bare `.replace` flagged `str.replace()`, which is why the guarantee had never been extended past one file — and (b) that `wake-digest.py`, a delegate **since before today**, was never covered. Its only real mutation is `emit_brief()`, its SessionStart-hook role, unreachable from any tool. Now handled by **declared exemption per delegate**, so a new mutating function fails until someone names it and says why — the same shape as PENDING-165's allowlist, and for the same reason. + +**Awaiting:** nothing on (c)/(d). The 270-candidate classification pass is unscheduled and unclaimed. diff --git a/scripts/governance-mcp.py b/scripts/governance-mcp.py index f85cd6b..6d4694d 100644 --- a/scripts/governance-mcp.py +++ b/scripts/governance-mcp.py @@ -63,6 +63,7 @@ def _load(mod_name, filename): wd = _load("wake_digest", "wake-digest.py") # the single definition of "open item" +pa = _load("prior_art", "prior-art.py") # PENDING-164 (c): commit history, no window # ---- the enumerated domain: keys, never paths ------------------------------- FILES = { @@ -428,6 +429,53 @@ def t_pair(args): return "\n".join(out) +# ── PENDING-164 (c) ─────────────────────────────────────────────────────────────── +# repo_activity caps at 100 commits. At chamber-library's rate that floor sat five weeks +# short of 0677e8a — the commit retiring LFS — so when the jurist recommended adopting +# LFS on 2026-08-26, NO instrument available to it could have reached the refutation. +# 'Search prior art before proposing' was not a rule the jurist could follow. +# +# This removes the asymmetry rather than papering over it: one implementation +# (prior-art.py), two consumers — the executor's CLI and this tool — so the answer cannot +# differ by surface. It returns the substrate, not testimony about it. +def t_prior_art(args): + """Commit-message history for a named mechanism, across every owned repo, unbounded.""" + term = (args.get("term") or "").strip() + if not term: + return "ERROR: term is required (the mechanism's name, e.g. 'LFS', 'submodule')." + if len(term) < 2: + return "ERROR: term too short to be discriminating." + ok, notes, _ = pa.controls() + hits = pa.search_commits(term) + reg = pa.register_mentions(term) + out = [] + if not ok: + out.append("⚠ INSTRUMENT NOT VERIFIED — result unestablished. " + + "; ".join(notes)) + out.append("") + out.append(f"PRIOR ART: {term!r}") + out.append(f" commits mentioning it : {len(hits)} (all branches, NO count window)") + out.append(f" register mentions : {reg} (PENDING, PENDING-archive, REVIEWED)") + out.append("") + for repo, sha, date, subj in sorted(hits, key=lambda h: h[2])[:60]: + out.append(f" {date} {repo:32} {sha} {subj[:72]}") + if len(hits) > 60: + out.append(f" … {len(hits) - 60} further commit(s) not listed.") + out.append("") + if hits and reg == 0: + out.append(" \u26a0 FINDING — PENDING-164's condition exactly: this mechanism has a") + out.append(" history in the repos and NO trace in the authorization record. Whatever") + out.append(" was decided about it was decided in a commit message. Read those") + out.append(" commits before proposing anything about it.") + elif hits: + out.append(" Both surfaces carry it. A ruling can post-date the commit that") + out.append(" motivated it, or precede the one that undid it — read both.") + else: + out.append(" No prior art. \u26a0 Weak absence: it means no COMMIT MESSAGE names this") + out.append(" term, not that nothing was decided about it.") + return "\n".join(out) + + TOOLS = [ ("governance_state", t_state, "Current governance state, computed live: every open authorization item with its " @@ -474,6 +522,16 @@ TOOLS = [ "Full governance-drift-check output: claims in CLAUDE.md the substrate contradicts. " "Detection only — correcting doctrine requires steward authorization.", {"type": "object", "properties": {}}), + ("prior_art", t_prior_art, + "Commit-message history for a named mechanism across every owned repo, with NO " + "commit-count window, plus whether the authorization register mentions it. Use this " + "BEFORE proposing or ruling on any named mechanism — repo_activity caps at 100 " + "commits and that floor has already hidden a decision this system had made. Commits " + "with zero register mentions is the finding, not the noise.", + {"type": "object", "properties": { + "term": {"type": "string", + "description": "The mechanism's name, e.g. 'LFS', 'submodule', 'worktree'."}}, + "required": ["term"]}), ("repo_activity", t_repo, "Branch, uncommitted-file status and recent commits for one of the active repos.", {"type": "object", "properties": { @@ -550,8 +608,16 @@ def write_calls(src): duly found all of them, in its own definition. That is the day's recurring shape: an instrument whose domain includes itself. The AST sees calls, not characters.""" import ast - MUTATORS = {"remove", "unlink", "rename", "replace", "rmdir", "mkdir", "makedirs", + MUTATORS = {"remove", "unlink", "rename", "rmdir", "mkdir", "makedirs", "chmod", "truncate", "write", "writelines", "write_text", "write_bytes"} + # `replace` is NOT in MUTATORS as a bare attribute: str.replace() is ubiquitous and + # flagging it made this check unusable on any file that manipulates text — which is + # why it had never been extended past this file. os.replace/Path.replace ARE caught, + # by qualified name below. Narrowed 2026-08-26 when extending the guarantee to + # delegates surfaced three false positives in wake-digest.py (lines 142, 150, 888, + # every one a string replace) alongside one real write. + QUALIFIED = {("os", "replace"), ("shutil", "move"), ("shutil", "rmtree"), + ("shutil", "copy"), ("shutil", "copy2"), ("shutil", "copytree")} out = [] for n in ast.walk(ast.parse(src)): if not isinstance(n, ast.Call): @@ -565,6 +631,9 @@ def write_calls(src): out.append(f"open(mode={mode!r}) at line {n.lineno}") elif isinstance(f, ast.Attribute) and f.attr in MUTATORS: out.append(f"{f.attr}() at line {n.lineno}") + elif (isinstance(f, ast.Attribute) and isinstance(f.value, ast.Name) + and (f.value.id, f.attr) in QUALIFIED): + out.append(f"{f.value.id}.{f.attr}() at line {n.lineno}") return out @@ -693,6 +762,19 @@ def selftest(): "2026-08-05; this control goes red if a header is ever hidden again]", "MATCHES OUTSIDE ANY ITEM" not in t_search({"query": "the"})) + print("\nprior_art — PENDING-164 (c): the window that hid 0677e8a:") + chk("prior_art returns the commit repo_activity's 100-window could not reach " + "[0677e8a, 2026-06-05, five weeks past the floor]", + "0677e8a" in t_prior_art({"term": "LFS"})) + chk("prior_art reaches dotfiles, which is NOT in REPOS [95760ff]", + "95760ff" in t_prior_art({"term": "LFS"})) + chk("prior_art reports the register count alongside the commits " + "[the asymmetry IS the finding]", + "register mentions" in t_prior_art({"term": "LFS"})) + chk("prior_art on a nonsense term reports no prior art, and calls the absence weak " + "[negative control]", + "No prior art" in t_prior_art({"term": "zzqqxx-not-a-real-term-9971"})) + chk("prior_art refuses an empty term", t_prior_art({}).startswith("ERROR")) chk("repo_activity refuses an unlisted repo", t_repo({"repo": "/etc"}).startswith("ERROR: unknown repo")) chk("repo_activity ACCEPTS a listed repo [positive control]", @@ -740,6 +822,42 @@ def selftest(): src = open(__file__, encoding="utf-8").read() chk("no filesystem-mutating call in this file", write_calls(src) == []) + # ⚠ The guarantee is scoped to the file the AST reads. Adding prior_art (PENDING-164 + # (c)) put a DELEGATION outside that scope: t_prior_art calls into prior-art.py, whose + # code this check never saw. A structural guarantee with a hole where it delegates is + # the shape of every other defect in this thread — so the delegate is checked too, and + # any future delegate must be added here or the guarantee silently narrows. + # Each delegate must have NO mutating call, except in functions DECLARED here as + # unreachable from this server. Declared, never inferred — the same shape as the hook + # allowlist: a new mutating function in a delegate fails until someone names it and + # says why. wake-digest.py is also a SessionStart hook, and emit_brief() is its hook + # role; no tool in this file calls it. + _delegates = {"prior-art.py": set(), "wake-digest.py": {"emit_brief"}} + for _fn, _exempt in sorted(_delegates.items()): + _dsrc = open(os.path.join(SCRIPTS, _fn), encoding="utf-8").read() + _tree = __import__("ast").parse(_dsrc) + _spans = {f.name: (f.lineno, f.end_lineno) for f in __import__("ast").walk(_tree) + if isinstance(f, __import__("ast").FunctionDef)} + _bad = [] + for _call in write_calls(_dsrc): + _ln = int(_call.rsplit(" ", 1)[-1]) + _in = [n for n, (a, b) in _spans.items() if a <= _ln <= (b or a)] + if not any(n in _exempt for n in _in): + _bad.append(f"{_call} in {_in or ['']}") + chk(f"DELEGATE {_fn}: no mutating call outside its declared exemptions {sorted(_exempt) or '(none)'} " + "[the read-only guarantee must not stop at this file's edge]", + _bad == [], ) + if _bad: + for _b in _bad: + print(f" {_b}") + chk("the delegate check DOES flag an undeclared mutation [positive control — an " + "exemption list that never refuses is not a check]", + write_calls("import os\nos.remove('x')\n") != []) + chk("str.replace() is NOT flagged as a mutation [negative control — it was, and that " + "false positive is why this guarantee had never been extended]", + write_calls("s = 'a'.replace('a','b')\n") == []) + chk("os.replace() IS still flagged [positive control for the narrowing above]", + write_calls("import os\nos.replace('a','b')\n") != []) chk("the checker DOES flag writes when present [positive control — a text search " "here would match its own token list, which is how the first version of this " "check failed]", diff --git a/scripts/prior-art.py b/scripts/prior-art.py new file mode 100755 index 0000000..584b62e --- /dev/null +++ b/scripts/prior-art.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""Prior-art search over commit history — PENDING-164 options (c) and (d). + +WHY THIS EXISTS + On 2026-06-05 the steward adopted Git LFS in chamber-library, found it a misfit, + retired it, and rewrote seventeen commits to undo it (0677e8a). A per-repo hook + exemption replaced it (400c054). Neither decision appears anywhere in PENDING.md, + PENDING-archive.md or REVIEWED.md. + + Twelve weeks later the executor filed PENDING-163 recommending a route toward LFS, + and the jurist recommended adopting it outright. Three exchanges were spent before + the refutation surfaced, and it surfaced by accident — the string 'pre-lfs-export' + appeared in an unrelated directory listing. + + The symptom was two parties reasoning toward a retired mechanism. The disease is that + the record where such a decision is supposed to be findable does not contain it, so + the only party who could have found it is the one with a filesystem. That is the + asymmetry the three-party model exists to work around. + +WHAT IT DOES + (c) Makes the substrate REACHABLE rather than copying it: searches commit messages + across the steward's repos, unbounded by any commit-count window, and reports + whether the same term appears in the authorization register. Exposed to the + jurist through governance-mcp.py, whose repo_activity caps at 100 commits — a + floor that sat five weeks short of 0677e8a. + + (d) Gives the executor a pre-proposal check: before any [PROPOSAL] naming a mechanism + by name, run this and report the result. Mechanizes the discipline on the side + that has the filesystem. + + --census runs the one-time backward sweep (PENDING-164 AMENDMENT 1): adoption and + retirement verbs across every owned repo, each hit checked against the register. + +THE FINDING THIS LOOKS FOR + Not "were there commits". The asymmetry: commits mention it, the register does not. + That is a decision taken in a repo and never routed into the authorization record. + +ENUMERATION IS COMPUTED, NOT HAND-HELD + Owned repos are those whose remotes point at the steward's hosts. A hand-maintained + list is the failure mode PENDING-108 already measured: it is correct until someone + forgets, and nothing reports the forgetting. Note the positive control below forced + this: 0677e8a is in chamber-library and 95760ff is in dotfiles, and dotfiles is not + in governance-mcp.py's REPOS. +""" + +import re +import subprocess +import sys +from pathlib import Path + +HOME = Path.home() +OWNED_HOSTS = ("github.com/davidglidden", "davidglidden/", "git.skemantix.com") +REGISTER = [HOME / "PENDING.md", HOME / "PENDING-archive.md", HOME / "REVIEWED.md"] + +# The census's verbs. Deliberately about ADOPTION and RETIREMENT of mechanisms, not about +# ordinary change — "fix", "update" and "refactor" would return everything and measure +# nothing. +# ⚠ NARROWED after the first run, and the number is recorded because it is the finding. +# The first verb set — which included "remove", "replace with", "no longer", "switch to" — +# returned 661 candidates across 8 repos. That is a haystack, not a census: those verbs +# catch ordinary development. Narrowed to forms that are almost always ABOUT A MECHANISM +# rather than about a file. +# +# ⚠ AND THE HONEST LIMIT, stated here rather than discovered later: the jurist's +# specification has two halves — grep the verbs, THEN check each hit against the register. +# The first half is mechanical and is what this does. The second requires reading each +# commit to identify WHICH mechanism it decided about, and that is interpretation, not +# extraction. This instrument does not do it and does not pretend to. Same shape as +# PENDING-151 step 1, where propositions could not be extracted mechanically either. +CENSUS_VERBS = ["retire", "retired", "retiring", "abandon", "deprecat", "migrat", + "stop using", "move away from", "back out", "revert to", + "no longer use", "no longer using", "roll back", "un-adopt"] + + +def sh(cmd, cwd=None): + """stdout REGARDLESS of exit code. + + ⚠ Earned, not stylistic. The first form returned "" on a non-zero exit. `find` over + $HOME exits 1 because 154 directories under Library are unreadable — while printing + all 37 repos to stdout. So the repo list came back empty, every search returned zero, + and the instrument would have reported "no prior art" for everything. The positive + control caught it; without the control it was a clean, confident, wrong zero — the + exact failure this whole item is about. git returns empty stdout when it genuinely + fails, so nothing false is admitted by ignoring the code here.""" + try: + r = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=90) + return r.stdout + except Exception: + return "" + + +def owned_repos(): + """Repos whose remotes point at the steward's hosts. Computed, never listed.""" + out = sh(["find", str(HOME), "-maxdepth", "5", "-type", "d", "-name", ".git"]) + repos = [] + for line in out.splitlines(): + if not line.strip(): + continue + r = Path(line).parent + rem = sh(["git", "-C", str(r), "remote", "-v"]) + if any(h in rem for h in OWNED_HOSTS): + repos.append(r) + return sorted(set(repos)) + + +def search_commits(term, repos=None): + """Every commit whose message mentions `term`, in any branch, with NO count window.""" + hits = [] + for r in (repos if repos is not None else owned_repos()): + out = sh(["git", "-C", str(r), "log", "--all", "--format=%h\t%ad\t%s", + "--date=short", "-i", f"--grep={term}"]) + for line in out.splitlines(): + parts = line.split("\t", 2) + if len(parts) == 3: + hits.append((r.name, parts[0], parts[1], parts[2])) + return hits + + +def register_mentions(term): + """Items in the authorization record mentioning `term`. The other half of the asymmetry.""" + n = 0 + pat = re.compile(re.escape(term), re.I) + for f in REGISTER: + if f.exists(): + n += len(pat.findall(f.read_text(errors="replace"))) + return n + + +def controls(): + """Same-run positive controls. The jurist specified these: the instrument must return + two commits already known to exist, or it measured nothing and its zero means nothing.""" + ok, notes = True, [] + hits = search_commits("LFS") + shas = {h[1] for h in hits} + for known, why in (("0677e8a", "chamber-library — LFS retired, 17 commits rewritten"), + ("95760ff", "dotfiles — global git-lfs hooks removed")): + if not any(s.startswith(known[:7]) for s in shas): + ok = False + notes.append(f"CONTROL FAIL: {known} not returned ({why})") + # negative control: a term that cannot plausibly be in any commit message + if search_commits("zzqqxx-not-a-real-term-9971"): + ok = False + notes.append("CONTROL FAIL: a nonsense term returned hits") + return ok, notes, len(hits) + + +def report(term): + ok, notes, _ = controls() + if not ok: + print("⚠ INSTRUMENT NOT VERIFIED — the result below is unestablished.") + for n in notes: + print(" " + n) + print() + hits = search_commits(term) + reg = register_mentions(term) + print(f"PRIOR ART: {term!r}") + print(f" commits mentioning it : {len(hits)} (all branches, no count window)") + print(f" register mentions : {reg} (PENDING, PENDING-archive, REVIEWED)") + print() + for repo, sha, date, subj in sorted(hits, key=lambda h: h[2]): + print(f" {date} {repo:34} {sha} {subj[:70]}") + print() + if hits and reg == 0: + print(" ⚠ FINDING — PENDING-164's condition exactly: this mechanism has a history in") + print(" the repos and NO trace in the authorization record. Whatever was decided") + print(" about it was decided in a commit message. Read those commits before") + print(" proposing anything about it.") + elif hits: + print(" Both surfaces carry it. Read the register items AND the commits — a ruling") + print(" can post-date the commit that motivated it, or precede the one that undid it.") + else: + print(" No prior art found. ⚠ Absence here is weak evidence: it means no COMMIT") + print(" MESSAGE names this term, not that nothing was decided about it.") + return 0 if ok else 2 + + +def census(): + ok, notes, nlfs = controls() + print("BACKWARD CENSUS — PENDING-164 AMENDMENT 1") + print(f" instrument verified: {'YES' if ok else 'NO'}" + + ("" if ok else " ⚠ RESULT UNESTABLISHED")) + for n in notes: + print(" " + n) + repos = owned_repos() + print(f" owned repos (computed from remotes): {len(repos)}") + print(f" positive control: 'LFS' returns {nlfs} commits incl. 0677e8a and 95760ff") + print() + seen, rows = set(), [] + for verb in CENSUS_VERBS: + for repo, sha, date, subj in search_commits(verb, repos): + if sha in seen: + continue + seen.add(sha) + rows.append((date, repo, sha, subj)) + print(f" candidate adoption/retirement commits: {len(rows)}") + print(" (a candidate is a commit whose SUBJECT reads like a mechanism decision;") + print(" classification is the reader's, not this instrument's)") + print() + for date, repo, sha, subj in sorted(rows, reverse=True): + print(f" {date} {repo:34} {sha} {subj[:74]}") + return 0 if ok else 2 + + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "--census": + sys.exit(census()) + if len(sys.argv) > 1 and sys.argv[1] == "--selftest": + ok, notes, n = controls() + print(f"controls: {'PASS' if ok else 'FAIL'} ('LFS' → {n} commits)") + for x in notes: + print(" " + x) + sys.exit(0 if ok else 1) + if len(sys.argv) < 2: + print("usage: prior-art.py | --census | --selftest") + sys.exit(64) + sys.exit(report(" ".join(sys.argv[1:])))