diff --git a/scripts/governance-mcp.py b/scripts/governance-mcp.py index 191b1fb..5123457 100644 --- a/scripts/governance-mcp.py +++ b/scripts/governance-mcp.py @@ -36,8 +36,10 @@ Provenance: 2026-07-28, on PENDING-81's MCP leg. Sibling of wake-digest.py and governance-drift-check.py; imports the first. """ import importlib.util +import inspect import json import os +import re import subprocess import sys import time @@ -160,6 +162,107 @@ def t_read(args): + "\n".join(chunk) + more) +SEARCH_KEYS = ("pending", "pending-archive", "reviewed") + + +def t_search(args): + """Keyword search across the authorization corpus — PENDING-86 option (d). + + The failure this exists for is NOT 'the jurist cannot read item X'. It is + 'the jurist cannot DISCOVER item X whose id it does not already know' — the + 2026-07-29 case where a ruling demanded an outcome REVIEWED-74 had settled + four days earlier, in a file the jurist could read but had no reason to open. + Keyed retrieval cannot fix that; only search can. + + Three deliberate properties: + * The result unit is the ITEM, because the jurist's next move is + governance_item(id). Boundaries come from wd.item_spans — no second + definition of 'an item' (the 2026-07-28 bug). + * Terms are ANDed, and that is disclosed, because a silently conjunctive + matcher is exactly how recall dies as a question lengthens (the engine's + PENDING-97, found 2026-08-04). A zero-result search says what it + searched and how, so silence is legible rather than bare. + * A raw-text pass runs BESIDE the item pass and reports matches lying + outside every span item_spans can see — an indented header, a region + the parser drops. Search reveals the hole instead of papering it. + """ + q = (args.get("query") or "").strip() + if not q: + return "ERROR: query is required, e.g. 'order attestation' or 'two-column'." + terms = [t for t in q.lower().split() if t] + lim = max(1, min(int(args.get("limit") or 12), 50)) + + hits, scanned, orphan_lines = [], 0, [] + for key in SEARCH_KEYS: + text = read(FILES[key][0]) + if text is None: + continue + fname = os.path.basename(FILES[key][0]) + lines = text.split("\n") + covered = set() + for head, start, end in wd.item_spans(text): + scanned += 1 + covered.update(range(start, end)) + body = "\n".join(lines[start - 1:end - 1]) + low = body.lower() + if not all(t in low for t in terms): + continue + n = sum(low.count(t) for t in terms) + phrase = q.lower() in low + snippet = "" + for ln in body.split("\n"): + if any(t in ln.lower() for t in terms): + snippet = " ".join(ln.split())[:200] + break + hits.append((phrase, n, head, fname, start, snippet)) + # Structural health pass, deliberately INDEPENDENT of the query: an item header + # hidden by leading whitespace is invisible to item_spans, so it can never appear + # in results and its absence would look like a genuine miss. Query-independent + # because the jurist needs to know the index is incomplete whatever it searched. + # Narrow by construction — an earlier draft flagged any uncovered matching line + # and drowned the signal in each file's preamble, which is exactly how a warning + # stops being read. + infence = False + for i, ln in enumerate(lines, 1): + if ln.lstrip().startswith("```"): + infence = not infence + continue + if infence or i in covered: + continue + m = re.match(r"^\s+## ((?:PENDING|REVIEWED|COMPLETED)-\S+)", ln) + if m: + orphan_lines.append((fname, i, " ".join(ln.split())[:160])) + + hits.sort(key=lambda h: (not h[0], -h[1])) + mode = ("exact phrase, else all-terms-present" if len(terms) > 1 + else "single term, substring") + o = [f"[governance_search {q!r} — {len(hits)} matching item(s) of {scanned} scanned " + f"across {', '.join(SEARCH_KEYS)}]", + f"match mode: ALL {len(terms)} term(s) must appear in the same item " + f"({mode}); ranked by exact-phrase first, then raw term-count — " + f"term-count is a computed field, not a relevance score.", ""] + if not hits: + o.append("NO ITEM MATCHED. This is a legible empty, not a claim that the corpus " + "is silent on the subject: the matcher is CONJUNCTIVE, so a longer query " + "narrows fast. Retry with fewer or more common terms before concluding " + "nothing is on file.") + for phrase, n, head, fname, start, snip in hits[:lim]: + o.append(f"{'★ ' if phrase else ' '}{head} [{fname}:{start}] {n} hit(s)") + if snip: + o.append(f" … {snip}") + if len(hits) > lim: + o.append(f"\n[… {len(hits) - lim} more — raise limit (max 50)]") + if orphan_lines: + o.append("\n⚠ MATCHES OUTSIDE ANY ITEM THE PARSER CAN SEE — a header hidden by " + "leading whitespace, or a region item_spans drops. governance_item() " + "cannot retrieve these by id; they are reported here so the gap is " + "visible rather than silent:") + for fname, i, ln in orphan_lines[:10]: + o.append(f" {fname}:{i} {ln}") + o.append("\nNext: governance_item(id) for the verbatim body of any hit above.") + return "\n".join(o) + + def t_drift(_args): """Full drift-check output. Reports its own non-verification rather than a clean bill.""" out = wd.sh([sys.executable, wd.DRIFT], timeout=30) @@ -207,6 +310,17 @@ TOOLS = [ "offset": {"type": "integer", "description": "0-based first line (default 0)."}, "limit": {"type": "integer", "description": "Lines to return, max 2000 (default 400)."}}, "required": ["file"]}), + ("governance_search", t_search, + "Keyword search across PENDING.md, PENDING-archive.md and REVIEWED.md. Use this to " + "DISCOVER a relevant prior item or ruling whose id you do not already know — the case " + "governance_item cannot serve, because it needs the id up front. Terms are ANDed " + "within a single item; results name ids to pass to governance_item.", + {"type": "object", "properties": { + "query": {"type": "string", + "description": "Words that must all appear in the same item, e.g. " + "'order attestation' or 'two-column'. Not a regex."}, + "limit": {"type": "integer", "description": "Max items to list, max 50 (default 12)."}}, + "required": ["query"]}), ("drift_report", t_drift, "Full governance-drift-check output: claims in CLAUDE.md the substrate contradicts. " "Detection only — correcting doctrine requires steward authorization.", @@ -386,6 +500,31 @@ def selftest(): chk("a first-page read lands in the SUPERSEDED region [negative control: proves the " "trap is real, not hypothetical]", "(obsoleted)" in t_read({"file": "chamber-spec", "limit": 300})) + print("\nPENDING-86 (d) — discovery without knowing the id:") + _s = t_search({"query": "added-side fabrication"}) + chk("search finds REVIEWED-74 from CONTENT alone [the 2026-07-29 failure: the jurist " + "demanded an outcome this ruling had settled four days earlier, and could not find it]", + "REVIEWED-74" in _s) + chk("search spans the archive too, not just open items", + "PENDING-archive.md" in _s) + chk("results carry ids to hand to governance_item [the two tools compose]", + "governance_item(id)" in _s) + _e = t_search({"query": "zzzq nonexistent phrase"}) + chk("a miss is a LEGIBLE empty, not a bare one [it discloses the conjunctive matcher, " + "the failure shape that killed engine recall — PENDING-97]", + "0 matching item(s)" in _e and "CONJUNCTIVE" in _e) + chk("the match mode is stated on EVERY result, hit or miss [positive control for the above]", + "match mode:" in _s and "match mode:" in _e) + chk("empty query refused rather than matching everything", + t_search({"query": " "}).startswith("ERROR: query is required")) + chk("search takes no path and no file key — corpus is a fixed tuple", + set(SEARCH_KEYS).issubset(FILES) and len(SEARCH_KEYS) == 3) + chk("no second definition of 'an item' — search reuses wd.item_spans [the 07-28 bug]", + "wd.item_spans" in inspect.getsource(t_search)) + chk("orphan pass reports NOTHING today [the three indented headers were unhidden " + "2026-08-05; this control goes red if a header is ever hidden again]", + "MATCHES OUTSIDE ANY ITEM" not in t_search({"query": "the"})) + chk("repo_activity refuses an unlisted repo", t_repo({"repo": "/etc"}).startswith("ERROR: unknown repo")) chk("repo_activity ACCEPTS a listed repo [positive control]",