diff --git a/PENDING.md b/PENDING.md index b9087e3..c88af46 100644 --- a/PENDING.md +++ b/PENDING.md @@ -410,3 +410,52 @@ So the goal is not "make them derived." It is: **keep the cache small, generate **Files affected:** Claude.app personal preferences (steward edits). Already landed, detection/generation only: `~/dotfiles/scripts/wake-digest.py --brief`. **Awaiting:** Steward decision on finding #1 (is Cowork a party?), and authorization for the §Standing Context split. + +## PENDING-82 — Read-only MCP server: giving the jurist eyes on the substrate +**Date:** 2026-07-28 +**Tag:** [PROPOSAL] — new interface between two governing parties. Built and self-tested; **not installed.** Installing it edits the steward's desktop-app config. +**Summary:** `~/dotfiles/scripts/governance-mcp.py` publishes the governance substrate to Claude.app's chat surface as five read-only tools, closing the gap PENDING-81 could only narrow. Supersedes PENDING-81's premise that a generated cache is the best available answer — for chat, it is no longer the only one. + +**The gate PENDING-81 left open is answered, and my framing of it was backwards.** Steward-confirmed 2026-07-28: local MCP servers configured in `claude_desktop_config.json` are exposed to the **chat** surface, and have been since roughly a year before Cowork existed — never Cowork-gated. Cowork gets them *conditionally*: local sessions inherit them, remote sessions — now the default execution mode being rolled out — do not run local MCP at all. So the relationship is not "chat, not only Cowork" but **"chat, always; Cowork, only while it still runs locally."** The jurist chat sits on the stable side of that split, which makes this design *less* exposed to product drift than the Cowork-dependent one considered and rejected on 2026-07-28. + +**Substrate check:** `claude_desktop_config.json` has **no `mcpServers` key** (top-level keys: `coworkUserFilesPath`, `preferences`). Its `preferences` block is app **UI state** — sidebar mode, pinned panes, Cowork toggles — *not* the jurist's Standing Context prose. Name collision only; PENDING-81's finding that the live preferences are nowhere on disk **stands unrevised.** + +**What it exposes (five tools, all read-only):** +- `governance_state` — every open item with its `[TAG]`, recent rulings, drift count, per-repo status. Computed per call. +- `governance_item(id)` — the **verbatim** body of any item or ruling, across `PENDING.md`, `PENDING-archive.md`, `REVIEWED.md`. This is the capability a pasted cache can never have: the jurist can read the thing it is ruling on. +- `governance_read(file, offset, limit)` — verbatim paged read of one of six enumerated documents. +- `drift_report()` — full `governance-drift-check.py` output. +- `repo_activity(repo, count)` — branch, dirty count, recent commits. + +**Four refusals designed in, each with a control that proves the refusal detectable:** +1. **Read-only.** No tool writes. Audited by AST, not by text search: 0 filesystem-mutating calls, git subcommands present = `{log, status}` only. A write path would collapse three parties into one. +2. **Not an agent.** Tools return data, verbatim where possible. The rejected alternative — a second Claude with filesystem access reporting back — yields *an agent's testimony about the substrate*, not the substrate. +3. **No second parser.** "An item" is defined once, in `wake-digest.py`'s `item_spans()`, imported here. A private second definition is how twenty items went missing on 2026-07-28. +4. **No path arguments.** Every tool takes a key from a fixed enum. There is no traversal to defend because no path is accepted; the reachable domain is enumerable rather than defined by the instrument. + +**Verified:** 27 self-test controls pass, each pairing an absence with a same-run positive control (Q2). Plus a live stdio round-trip — `initialize` → `notifications/initialized` (correctly unanswered) → `tools/list` → two `tools/call` → malformed input survived as a `-32700` rather than a crash; stdout carried only JSON-RPC, stderr empty. + +**[FIX] applied to the shared definition while here.** `item_spans()` is now **fence-aware**: a `## ` header inside a fenced code block is neither an item nor an item boundary. Zero such headers exist in the substrate today, so behaviour is unchanged (17 open items before and after) — but governance drafts are written as fenced markdown carrying `## REVIEWED-N` headers, per the steward's own copy-paste-clean practice, so the next such draft would have created a phantom item *and* truncated the real item containing it. Latent defect with a live trigger, not a hypothetical. + +**⚑ A false pointer in yesterday's own resumption point.** It stated the two §Your Role edits were "drafted verbatim in the transcript **and in PENDING-81**." They are not in PENDING-81; they existed only in a transcript discarded at the restart. Same wrap that mis-stated the archive. The lesson is the one already in doctrine: *a draft that lives in a transcript is not a record.* Re-drafted below, in the file this time. + +**Installation (steward's hand — it edits the app's config, and the app must restart):** +```json +{ + "mcpServers": { + "governance": { + "command": "python3", + "args": ["/Users/davidglidden/dotfiles/scripts/governance-mcp.py"] + } + } +} +``` +Merge that `mcpServers` key into `~/Library/Application Support/Claude/claude_desktop_config.json` alongside the existing `coworkUserFilesPath` and `preferences` keys, then restart Claude.app. Reversal is deleting the key. + +**Cowork retirement — §Your Role replacement text (re-drafted; the steward places it, since the surrounding prose is not readable from here):** +> Three parties hold distinct roles: **steward** (David) authorizes; **jurist** (Claude.app) proposes and governs; **executor** (Claude Code) implements within authorization. + +Delete the Cowork party entry and every reference to `COWORK.md`. Grounds, now two: a third executor costs a third doctrine copy of a document that is `CLAUDE.md` with the nouns changed; and Cowork could not have served as the jurist's filesystem eyes even in principle, since `coworkUserFilesPath` points at `~/Claude`, which does not exist, and remote Cowork — the incoming default — runs no local MCP at all. + +**Files affected:** new `~/dotfiles/scripts/governance-mcp.py`; `~/dotfiles/scripts/wake-digest.py` (`item_spans()` fence-awareness + 3 controls). Awaiting steward hand: `claude_desktop_config.json`, Claude.app §Your Role and §Standing Context. +**Awaiting:** Steward authorization to install the `mcpServers` key. The server itself is inert until then — nothing loads it. diff --git a/claude/memory/session-ledger-2026-07-28.md b/claude/memory/session-ledger-2026-07-28.md index 7775ee8..b7a39d7 100644 --- a/claude/memory/session-ledger-2026-07-28.md +++ b/claude/memory/session-ledger-2026-07-28.md @@ -19,6 +19,10 @@ type: feedback - 2026-07-28T11:25 — ⚑ **The union verifier found 3 deficits, and the diagnosis matters more than the count.** All three are *header* lines, absent by intent: the stale `Repo: bmf` and `Branch: fix/replay-durability-contracts` pointers (that branch merged as `c9746ae`, HEAD is `main` — staleness already named in PENDING-78) plus the `Protocol:` line, reflowed. No governance content was lost. **But the header was rewritten inside `8abfe88` while the commit message mentions only the split** — a real edit to a governance file, unlogged. Third instance today of *the domain a check does not cover*: the morning's losslessness proof was over item blocks, so a deliberate 3-line header change sat outside its unit of account and was invisible to a proof that reported "lossless." Logged into `7f6157a`'s message rather than left silent. Reusable: **a losslessness proof is only as wide as its unit of account — name the unit, then ask what in the file is not made of it.** - 2026-07-28T11:25 — Same class, caught while in there: the header asserted *"the next item is PENDING-80"* while 79, 80 and 81 all exist. Replaced the stated number with the rule that computes it (next = one above the highest `## PENDING-` in either file). A stated number is a drift source the drift-check cannot see, because it lives inside the file it describes. - 2026-07-28T11:35 — Fumbled this ledger three edits in a row: appended returns to the tail of **Authorization moves**, then created a **duplicate `## Open horizons`** heading, then a mid-file duplicate of `## Sub-agent dialogues`/`## Bypasses`. Rewrote the file whole rather than patching the patches. Small, but the shape is worth naming: **I was appending by anchor without holding the document's structure in view** — the same locality error as editing a section without reading the file. Cheap correction, no content lost; recorded rather than quietly tidied. +- 2026-07-28T12:10 — ⚑⚑ **The day's question answered by walking into it three times, the third time inside the fix for the second.** Building the MCP server's read-only guarantee: v1 checked for write primitives with `[w for w in ('"w"', "os.remove", "shutil.", …) if w in src]` — which **found all nine, in its own token list.** Rewrote it over the AST. Re-ran: the *git* half of the same check, left as `'"commit"' not in src`, now failed because the source it reads contains `"commit"` and `"push"` **as the literals of the check itself.** Two instances of one shape, the second surviving my repair of the first because I fixed the half that failed rather than the *class*. Fixed both over AST (`write_calls`, `git_subcommands` — argv lists, not characters). **This is the answer to the literal question, and it is not "audit each instrument":** a text search for forbidden words can never clear a file that must name those words, so the fix is to stop measuring in the medium the instrument is written in. Ask of any check: *is its evidence the same kind of thing as its own source?* Kin to [[feedback-checkable-claim-surfaces-bugs]] — the demand for a checkable claim exposed the defect twice in ten minutes. +- 2026-07-28T12:10 — ⚑ **A latent defect in the shared definition, with a trigger already in use.** `item_spans()` (the single definition of "an item") treated **any** `## ` line as a header, including inside fenced code blocks. Zero such headers exist in the substrate today — so no behaviour changed, 17 open items before and after — but governance drafts are written as fenced markdown carrying `## REVIEWED-N` headers, which is *the steward's own documented practice* ([[feedback-governance-drafting-copy-paste-clean]]). The next such draft would have produced a phantom item **and** truncated the real item containing it. Now fence-aware, with a paired control (fenced → ignored; same text unfenced → found). Confirmed load-bearing within the hour: PENDING-82's own body carries a fenced JSON block and spans correctly (L414–463). Reusable: **when checking whether a rule is sound, ask not "does the substrate violate it today" but "what practice already in use would violate it tomorrow."** +- 2026-07-28T12:10 — ⚑ **A false pointer in yesterday's resumption point, in my own hand.** It said the two §Your Role edits were "drafted verbatim in the transcript **and in PENDING-81**." They are not in PENDING-81 — they existed only in a transcript the restart discarded. Same wrap that mis-stated the archive: **two false claims about where work lived, from one wrap.** Both are the same error as an unstaged file — believing something is recorded because I produced it. Re-drafted into PENDING-82, in the file. Doctrine already said this (*a draft that lives in a transcript is not a record*); the wrap protocol is where it failed to bite. +- 2026-07-28T12:10 — My selftest asserted an item body contains **zero** `## ` headers; it contains exactly one, its own. Failed against correct code. Third instance of 09:35's shape (a control written before reading the output) — and note it was cheap to catch *because the control existed at all*. Rewrote to assert exactly-one-header plus a real boundary check, which PENDING-82's arrival immediately made non-vacuous. ## Open horizons @@ -45,6 +49,7 @@ type: feedback - 2026-07-28T10:40 — ⚑ **The `.app`/`CLAUDE.md` question has a structural answer, not a tooling one.** Their readers differ in filesystem access, so one document can *compute* its state and the other can only *cache* it — confirmed by substrate (the live preferences exist nowhere on disk; only March-era sandbox snapshots). **Duplication between them is structurally required; only its staleness is optional.** The corollary I nearly missed: a pointer is worthless to a reader who cannot open files, which is *why* the preferences accumulated state in the first place. Reusable: before proposing a single-source-of-truth, check whether every reader can reach the source. - 2026-07-28T10:40 — ⚑ **The party structure itself has drifted, and both documents are constitutional.** `CLAUDE.md` names three parties; the `.app` preferences name Cowork as a fourth under `COWORK.md` — a real document, dated Mar 22, orphaned in an agent-mode sandbox — while calling the model three-party. The MemPalace weld shape, one layer up: doctrine welded to a retired instrument, where the instrument is *a party*. No generated block can fix it; it needs a ruling. - 2026-07-28T11:25 — **Archive break repaired on steward authorization** (*"yes, absolutely"*): `7f6157a`, pushed `8abfe88..7f6157a` to `github/main`. `PENDING-archive.md` now tracked; the record the remote carries is whole again. Verified **before** committing, not after: baseline `PENDING.md.bak-2026-07-28-pre-split` (1848 lines) ⊆ (`PENDING.md` ∪ `PENDING-archive.md`) at **line** granularity — no regex, no parser notion of "item" — with a same-run positive control (sentinel absent from the union → reported missing: true) plus a second control confirming the check is blind by design to the 65 post-split appends. Two [FIX]-class changes, both stated in the commit message rather than left to the diff: the archive add, and the header's self-contradicted counter. `~/CLAUDE.md` and `REVIEWED.md` untouched — Constraint #1 holds. +- 2026-07-28T12:10 — **PENDING-82 placed** (`~/dotfiles/PENDING.md` L414): the read-only MCP server, `[PROPOSAL]`. Built and self-tested (27 controls + a live stdio round-trip); **not installed** — the `mcpServers` key edits the steward's app config, so the snippet is handed over rather than applied. Appended as a new item rather than edited into PENDING-81, per that file's own append-only rule. Carries the re-drafted §Your Role replacement text and the second, stronger ground for Cowork's retirement (remote Cowork, the incoming default, runs no local MCP at all — so it could not have been the jurist's eyes even in principle). **No REVIEWED-81/82 drafted:** composing a ruling before the ruling is the say–do seam this ledger opened on at 08:12. Offered, not written. ## Sub-agent dialogues diff --git a/scripts/governance-mcp.py b/scripts/governance-mcp.py new file mode 100644 index 0000000..1e4fa75 --- /dev/null +++ b/scripts/governance-mcp.py @@ -0,0 +1,422 @@ +#!/usr/bin/env python3 +"""governance-mcp — read-only MCP server giving the jurist eyes on the substrate. + +WHY THIS EXISTS +The three-party model asks Claude.app (jurist) to rule on governance items it +cannot read. Its reader has no filesystem, so until now its picture of PENDING / +REVIEWED / drift could only be *cached* — pasted in by the steward and stale from +the moment it landed. `wake-digest.py --brief` narrows that gap; it does not close +it, because a snapshot cannot answer a question nobody anticipated. + +This closes it for Claude.app's **chat** surface, where local MCP servers have +always been exposed (steward-confirmed 2026-07-28). Cowork gets local MCP only +while its agent loop still runs on-device — the mode being phased out as default — +so a Cowork-dependent design would have been the fragile one. + +WHAT IT REFUSES TO BE +- **Read-only.** No tool writes, moves, or deletes. The jurist proposes; the + steward authorizes; the executor acts. An MCP write path would collapse three + parties into one. +- **Not an agent.** Every tool returns *data*, verbatim where possible. The + alternative considered and rejected was a second Claude with filesystem access + reporting back: that yields an agent's testimony about the substrate, not the + substrate. A tool returns data; an agent returns a claim. +- **No second parser.** "Open item" is defined ONCE, in wake-digest.py, imported + here. On 2026-07-28 a splitter's private definition of "item" hid twenty items, + ten of them open, and every check inherited the blind spot. Two implementations + of one field is that failure waiting to recur across a boundary nobody watches. +- **No path arguments.** Every tool takes a KEY from a fixed enum, never a path. + There is no traversal to defend against because there is no path to traverse — + and the reachable domain is enumerable rather than defined by the instrument. + + governance-mcp.py --selftest exercise every tool, presence AND absence + governance-mcp.py --stdio serve (how the app launches it; also the default) + +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 json +import os +import subprocess +import sys +import time + +HOME = os.path.expanduser("~") +D = os.path.join(HOME, "dotfiles") +SCRIPTS = os.path.join(D, "scripts") +NAME, VERSION = "governance", "1.0.0" + +# Newest protocol we speak; we echo the client's version when it sends a known one. +PROTOCOLS = ("2025-06-18", "2025-03-26", "2024-11-05") + + +def _load(mod_name, filename): + """Import a sibling script whose filename is not a valid module name.""" + spec = importlib.util.spec_from_file_location(mod_name, os.path.join(SCRIPTS, filename)) + m = importlib.util.module_from_spec(spec) + spec.loader.exec_module(m) + return m + + +wd = _load("wake_digest", "wake-digest.py") # the single definition of "open item" + +# ---- the enumerated domain: keys, never paths ------------------------------- +FILES = { + "pending": (os.path.join(D, "PENDING.md"), "open authorization items"), + "pending-archive": (os.path.join(D, "PENDING-archive.md"), "closed authorization items"), + "reviewed": (os.path.join(D, "REVIEWED.md"), "steward/jurist decisions"), + "claude-md": (os.path.join(D, "CLAUDE.md"), "the executor's governing document"), + "memory-index": (os.path.join(wd.MEM, "MEMORY.md"), "wake-loaded memory index"), + "app-brief": (wd.BRIEF_PATH, "last generated .app Standing Context block"), +} +REPOS = wd.REPOS + + +def read(path): + try: + return open(path, encoding="utf-8").read() + except Exception as e: + return None if isinstance(e, FileNotFoundError) else None + + +# ---- tool implementations (pure-ish; selftest exercises these directly) ----- + +def t_state(_args): + """The whole picture, computed now: open items with tags, recent rulings, drift, repos.""" + wd.warn.clear() + items, revs, drift, repos = wd.sec_pending(), wd.sec_reviewed(6), wd.sec_drift(), wd.sec_repos() + o = [f"GOVERNANCE STATE — computed {time.strftime('%Y-%m-%d %H:%M')} local, not cached.", + "", + f"OPEN AUTHORIZATION ITEMS ({len(items)}) — full body via governance_item(id=…)"] + for h, ln, tag in items: + o.append(f" {tag:<14} {h} [{FILES['pending'][0].rsplit('/', 1)[1]}:{ln}]") + o.append("\nRECENT RULINGS") + for h, d in revs: + o.append(f" {d:<11} {h}") + o.append(f"\nGOVERNANCE DRIFT — CLAUDE.md: {drift} substrate-contradicted claim(s)." + " Detection only; correcting doctrine requires [ESCALATE]. Detail via drift_report().") + o.append("\nREPOS (branch · uncommitted files · last subject)") + for r, head, dirty, subj in repos: + o.append(f" {r:<24} {head:<32} {dirty:>3} dirty {subj}") + if wd.warn: + o.append("\n⚠ DEGRADED — these could not be computed (treat as unknown, not as clean):") + o += [f" - {w}" for w in wd.warn] + return "\n".join(o) + + +def t_item(args): + """Verbatim body of one PENDING/REVIEWED/COMPLETED item, wherever it lives. + + Item boundaries use the same rule as wake-digest: an item starts at any '## ' + header and ends at the next one. No family regex — that is the 2026-07-28 bug.""" + ident = (args.get("id") or "").strip() + if not ident: + return "ERROR: id is required, e.g. 'PENDING-81' or 'REVIEWED-80'." + for key in ("pending", "pending-archive", "reviewed"): + text = read(FILES[key][0]) + if text is None: + continue + lines = text.split("\n") + for head, start, end in wd.item_spans(text): + if head == ident or head.startswith(ident + " "): + return (f"[{ident} — verbatim from {os.path.basename(FILES[key][0])}, " + f"line {start}]\n\n" + "\n".join(lines[start - 1:end - 1]).rstrip()) + return (f"NOT FOUND: no '## {ident}' header in PENDING.md, PENDING-archive.md, or " + f"REVIEWED.md. Use governance_state() to list open items by id.") + + +def t_read(args): + """Verbatim paged read of one enumerated file. No path argument by design.""" + key = (args.get("file") or "").strip() + if key not in FILES: + return ("ERROR: unknown file key %r. Allowed: %s" + % (key, ", ".join(f"{k} ({d})" for k, (_p, d) in FILES.items()))) + path, desc = FILES[key] + text = read(path) + if text is None: + return f"UNREADABLE: {key} ({desc}) is absent or unreadable at this time." + lines = text.split("\n") + off = max(0, int(args.get("offset") or 0)) + lim = max(1, min(int(args.get("limit") or 400), 2000)) + chunk = lines[off:off + lim] + more = ("" if off + lim >= len(lines) else + f"\n\n[… {len(lines) - off - lim} more lines — call again with offset={off + lim}]") + return (f"[{key} — {desc} — lines {off + 1}–{off + len(chunk)} of {len(lines)}]\n\n" + + "\n".join(chunk) + more) + + +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) + if out is None: + return ("UNESTABLISHED: governance-drift-check.py did not run. This is not a clean " + "result — the absence of findings from an instrument that failed to run " + "carries no information.") + return out + + +def t_repo(args): + """Recent commits for one enumerated repo. Repo name from a fixed list, never a path.""" + name = (args.get("repo") or "").strip() + if name not in REPOS: + return f"ERROR: unknown repo %r. Allowed: %s" % (name, ", ".join(REPOS)) + path = os.path.join(HOME, "_Dev", name) + if not os.path.isdir(os.path.join(path, ".git")): + return f"NOT A REPO: {name} has no .git at {path}." + n = max(1, min(int(args.get("count") or 15), 100)) + log = wd.sh(["git", "-C", path, "log", f"-{n}", "--format=%h %ad %s", "--date=short"]) + status = wd.sh(["git", "-C", path, "status", "-sb"]) + if log is None: + return f"UNREADABLE: git log failed in {name}." + return f"[{name}]\n\n{status}\n\nLAST {n} COMMITS\n{log}" + + +TOOLS = [ + ("governance_state", t_state, + "Current governance state, computed live: every open authorization item with its " + "[TAG], recent REVIEWED rulings, the CLAUDE.md drift count, and per-repo status. " + "Start here, then drill in with governance_item.", + {"type": "object", "properties": {}}), + ("governance_item", t_item, + "Verbatim text of one authorization item or ruling by id (e.g. PENDING-81, " + "PENDING-S4, REVIEWED-80). Searches PENDING.md, PENDING-archive.md and REVIEWED.md. " + "Never summarised.", + {"type": "object", "properties": {"id": {"type": "string", + "description": "Item id exactly as it appears after '## ', e.g. 'PENDING-81'."}}, + "required": ["id"]}), + ("governance_read", t_read, + "Verbatim paged read of a governance document, chosen by key (not by path).", + {"type": "object", "properties": { + "file": {"type": "string", "enum": sorted(FILES), + "description": "Which document to read."}, + "offset": {"type": "integer", "description": "0-based first line (default 0)."}, + "limit": {"type": "integer", "description": "Lines to return, max 2000 (default 400)."}}, + "required": ["file"]}), + ("drift_report", t_drift, + "Full governance-drift-check output: claims in CLAUDE.md the substrate contradicts. " + "Detection only — correcting doctrine requires steward authorization.", + {"type": "object", "properties": {}}), + ("repo_activity", t_repo, + "Branch, uncommitted-file status and recent commits for one of the active repos.", + {"type": "object", "properties": { + "repo": {"type": "string", "enum": REPOS, "description": "Repo name."}, + "count": {"type": "integer", "description": "Commits to list, max 100 (default 15)."}}, + "required": ["repo"]}), +] +IMPL = {n: f for n, f, _d, _s in TOOLS} + + +# ---- JSON-RPC over stdio ---------------------------------------------------- + +def handle(msg): + """-> response dict, or None for notifications (which must never be answered).""" + mid, method, params = msg.get("id"), msg.get("method"), msg.get("params") or {} + + def ok(result): + return {"jsonrpc": "2.0", "id": mid, "result": result} + + if mid is None: # notification + return None + if method == "initialize": + want = params.get("protocolVersion") + return ok({"protocolVersion": want if want in PROTOCOLS else PROTOCOLS[0], + "capabilities": {"tools": {}}, + "serverInfo": {"name": NAME, "version": VERSION}, + "instructions": "Read-only view of the CapableMind governance substrate. " + "Nothing here writes. Call governance_state first."}) + if method == "ping": + return ok({}) + if method == "tools/list": + return ok({"tools": [{"name": n, "description": d, "inputSchema": s} + for n, _f, d, s in TOOLS]}) + if method == "tools/call": + name = params.get("name") + if name not in IMPL: + return {"jsonrpc": "2.0", "id": mid, + "error": {"code": -32602, "message": f"unknown tool: {name}"}} + try: + text = IMPL[name](params.get("arguments") or {}) + return ok({"content": [{"type": "text", "text": text}]}) + except Exception as e: # a crash must not kill the session + return ok({"content": [{"type": "text", + "text": f"TOOL ERROR {type(e).__name__}: {e}"}], + "isError": True}) + return {"jsonrpc": "2.0", "id": mid, + "error": {"code": -32601, "message": f"method not found: {method}"}} + + +def serve(): + for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + msg = json.loads(line) + except Exception as e: + print(json.dumps({"jsonrpc": "2.0", "id": None, + "error": {"code": -32700, "message": f"parse error: {e}"}}), + flush=True) + continue + resp = handle(msg) + if resp is not None: + print(json.dumps(resp), flush=True) + + +# ---- selftest --------------------------------------------------------------- + +def write_calls(src): + """-> list of filesystem-mutating calls in `src`, found by AST rather than text. + + A grep for a forbidden-token list cannot audit the file that CONTAINS the list — + the first version of this check searched for `"w"`, `os.remove`, `shutil.` and + 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", + "chmod", "truncate", "write", "writelines", "write_text", "write_bytes"} + out = [] + for n in ast.walk(ast.parse(src)): + if not isinstance(n, ast.Call): + continue + f = n.func + if isinstance(f, ast.Name) and f.id == "open": + mode = next((a.value for a in n.args[1:2] if isinstance(a, ast.Constant)), None) + mode = next((k.value.value for k in n.keywords + if k.arg == "mode" and isinstance(k.value, ast.Constant)), mode) + if mode and any(c in str(mode) for c in "wax+"): + 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}") + return out + + +def git_subcommands(src): + """-> set of git subcommands invoked in `src`, read from AST argv literals. + + The third self-inclusion failure of 2026-07-28, and the one that finally made the + pattern obvious: after moving the write-primitive audit to the AST, the git half of + the same check was left as `'"commit"' not in src` — and the source it reads now + contains `"commit"` and `"push"` as the literals of the check itself. A text search + for forbidden words can never clear a file that must name those words. Read argv + lists, not characters: `["git", "-C", path, "log", …]` -> {"log"}.""" + import ast + out = set() + for n in ast.walk(ast.parse(src)): + if isinstance(n, ast.List) and n.elts: + first = n.elts[0] + if isinstance(first, ast.Constant) and first.value == "git": + for e in n.elts[1:]: + if not (isinstance(e, ast.Constant) and isinstance(e.value, str)): + continue # a variable (path, count) — skip + if e.value.startswith("-"): + continue # a flag, not the subcommand + out.add(e.value) + break + return out + + +def selftest(): + ok = True + + def chk(name, cond): + nonlocal ok + ok = ok and bool(cond) + print(f" [{'ok ' if cond else 'FAIL'}] {name}") + + print("protocol:") + init = handle({"jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": "2024-11-05"}}) + chk("initialize echoes a known client protocol", + init["result"]["protocolVersion"] == "2024-11-05") + chk("initialize falls back for an unknown protocol", + handle({"jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": "1999-01-01"}})["result"]["protocolVersion"] + == PROTOCOLS[0]) + chk("notifications get no response", + handle({"jsonrpc": "2.0", "method": "notifications/initialized"}) is None) + chk("tools/list returns all tools", + len(handle({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})["result"]["tools"]) + == len(TOOLS)) + chk("unknown method -> -32601", + handle({"jsonrpc": "2.0", "id": 3, "method": "nope"})["error"]["code"] == -32601) + chk("unknown tool -> -32602", + handle({"jsonrpc": "2.0", "id": 4, "method": "tools/call", + "params": {"name": "rm_rf"}})["error"]["code"] == -32602) + + print("\ndomain is an enum, and the refusal is detectable:") + chk("governance_read refuses an unlisted key", + t_read({"file": "secrets"}).startswith("ERROR: unknown file key")) + chk("governance_read refuses a path traversal attempt (no path is accepted at all)", + t_read({"file": "../../.ssh/id_rsa"}).startswith("ERROR: unknown file key")) + chk("governance_read ACCEPTS a listed key [positive control for the refusal above]", + t_read({"file": "pending"}).startswith("[pending —")) + chk("repo_activity refuses an unlisted repo", + t_repo({"repo": "/etc"}).startswith("ERROR: unknown repo")) + chk("repo_activity ACCEPTS a listed repo [positive control]", + t_repo({"repo": REPOS[0], "count": 3}).startswith(f"[{REPOS[0]}]")) + + print("\nitem lookup — presence, absence, and the 07-28 blind-spot family:") + chk("finds a numeric item", t_item({"id": "PENDING-81"}).startswith("[PENDING-81 — verbatim")) + chk("finds an S-series item [the family a regex-on-digits missed]", + t_item({"id": "PENDING-S4"}).startswith("[PENDING-S4 — verbatim")) + chk("finds a ruling in REVIEWED.md", + t_item({"id": "REVIEWED-80"}).startswith("[REVIEWED-80 — verbatim")) + chk("finds an item that lives only in the archive", + t_item({"id": "PENDING-72"}).startswith("[PENDING-72 — verbatim")) + chk("absent id -> NOT FOUND, not a silent empty", + t_item({"id": "PENDING-99999"}).startswith("NOT FOUND")) + chk("missing id -> explicit error", t_item({}).startswith("ERROR")) + chk("a shorter id does not match a longer one (PENDING-8 ≠ PENDING-81)", + t_item({"id": "PENDING-8"}).startswith("NOT FOUND")) + body = t_item({"id": "PENDING-81"}) + # The body carries exactly ONE '## ' header — its own. The first version of this + # check asserted ZERO and failed against correct code: the returned text is + # prefix + the item INCLUDING its header. A self-test written before reading real + # output tests the author's model of the output (07-28T09:35, same shape). + chk("item body contains exactly its own header, no neighbour's", + len([l for l in body.split("\n") if l.startswith("## ")]) == 1) + chk("item body ends before the next item begins", + "PENDING-82" not in body and "PENDING-80 —" not in body.split("\n", 3)[2]) + + print("\nno second definition of 'open item':") + state = t_state({}) + n_digest = len(wd.sec_pending()) + chk(f"governance_state item count == wake-digest sec_pending() ({n_digest})", + f"OPEN AUTHORIZATION ITEMS ({n_digest})" in state) + chk("state names its own degradation when something cannot be computed", + "DEGRADED" in state or not wd.warn) + + print("\nlive substrate:") + chk("PENDING.md readable", read(FILES["pending"][0]) is not None) + chk("REVIEWED.md readable", read(FILES["reviewed"][0]) is not None) + d = t_drift({}) + chk("drift_report runs and does not fake a clean result", + d and not d.startswith("UNESTABLISHED")) + + print("\nread-only guarantee (structural, via AST):") + src = open(__file__, encoding="utf-8").read() + chk("no filesystem-mutating call in this file", + write_calls(src) == []) + 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]", + len(write_calls("open('f','w')\nimport os\nos.remove('g')\np.write_text('h')")) == 3) + subs = git_subcommands(src) + chk(f"git is invoked read-only — subcommands present: {sorted(subs) or 'none'}", + subs and subs <= {"log", "status", "show", "rev-parse", "ls-files", "diff"}) + chk("the checker DOES flag a mutating subcommand [positive control]", + git_subcommands('subprocess.run(["git", "-C", p, "push", "origin", "main"])') + == {"push"}) + + print("\nSELFTEST", "PASS" if ok else "FAIL") + return 0 if ok else 2 + + +if __name__ == "__main__": + if "--selftest" in sys.argv: + sys.exit(selftest()) + try: + serve() + except (BrokenPipeError, KeyboardInterrupt): + pass diff --git a/scripts/wake-digest.py b/scripts/wake-digest.py index 3d28026..f9bfe71 100755 --- a/scripts/wake-digest.py +++ b/scripts/wake-digest.py @@ -47,16 +47,39 @@ def read(p): # ---------- extractors (pure functions of text, so --selftest can exercise them) ---------- +def item_spans(text): + """-> [(header, start, end)] for every item; 1-based start, end exclusive. + + THE single definition of "an item" for every consumer. Two lessons are welded in: + + - **Any `## ` header is an item.** A family-specific regex (`^## PENDING-`) + hid twenty items on 2026-07-28, ten of them open. Never match on family. + - **Fenced blocks are not the document's structure.** Governance drafts are + written as plain fenced markdown carrying their own `## REVIEWED-N` headers + (the steward's copy-paste-clean practice), so a fenced header would otherwise + register as a phantom item AND truncate the real item containing it. Zero such + headers exist in the substrate today; the trigger is a drafting habit already + in use, so this is a defect waiting on the next draft rather than a hypothetical. + """ + lines = text.split("\n") + heads, fence = [], False + for i, l in enumerate(lines): + s = l.lstrip() + if s.startswith("```") or s.startswith("~~~"): + fence = not fence + continue + if not fence and l.startswith("## "): + heads.append((l[3:].strip(), i + 1)) + return [(h, s, heads[k + 1][1] if k + 1 < len(heads) else len(lines) + 1) + for k, (h, s) in enumerate(heads)] + + def open_items(text): - """-> [(header, line_no)] for every '## ' item lacking closure evidence. - Closure evidence = a REVIEWED-N (resolved by the caller) or CLOSED/COMPLETED - in the header. Any '## ' line is an item — assuming one naming family missed - twenty items on 2026-07-28.""" - out = [] - for i, l in enumerate(text.split("\n"), 1): - if l.startswith("## ") and "CLOSED" not in l and not l.startswith("## COMPLETED"): - out.append((l[3:].strip(), i)) - return out + """-> [(header, line_no)] for every item lacking closure evidence. Closure + evidence = a REVIEWED-N (resolved by the caller) or CLOSED/COMPLETED in the + header.""" + return [(h, s) for h, s, _e in item_spans(text) + if "CLOSED" not in h and not h.startswith("COMPLETED")] def tag_of(text, header): @@ -290,6 +313,14 @@ def selftest(): chk("open_items excludes CLOSED / COMPLETED", open_items("## PENDING-1 — x (CLOSED)\n## COMPLETED — y") == []) chk("open_items returns empty on empty input", open_items("") == []) + FENCED = "## PENDING-9 — real\nbody\n```markdown\n## REVIEWED-9 — a draft\n```\ntail" + chk("open_items ignores a header inside a fenced block [drafting-habit trigger]", + [h for h, _ in open_items(FENCED)] == ["PENDING-9 — real"]) + chk("open_items DOES find that same header unfenced [positive control for the line above]", + [h for h, _ in open_items(FENCED.replace("```markdown\n", "").replace("```\n", ""))] + == ["PENDING-9 — real", "REVIEWED-9 — a draft"]) + chk("item_spans runs a fenced item to the tail, not to the fenced header", + item_spans(FENCED)[0][2] == 7) chk("tag_of reads a tag", tag_of("## PENDING-9 — t\n**Date:** d\n**Tag:** [ESCALATE]\n", "PENDING-9 — t") == "[ESCALATE]") chk("tag_of returns '' when absent", tag_of("## PENDING-9 — t\n", "PENDING-9 — t") == "")