diff --git a/PENDING.md b/PENDING.md index b3072ee..0c74af2 100644 --- a/PENDING.md +++ b/PENDING.md @@ -4666,6 +4666,50 @@ Applied here: the window supplies at most *something happened · nothing happene **Awaiting:** steward, on the content question — four options, after the beacon. **Not before the soul.** +### AMENDMENT 8 — 2026-08-25 — the unverified assumption is SETTLED, by this item's own method; and one clause does not survive it + +*Filed at implementation, against the substrate, not against documentation.* + +#### (a) ✅ The assumption this item flagged is settled — and it was settled the way the item said to settle it + +> *"Whether Claude Code re-renders the status line on a timer or only on session activity. Not verified… **Settle by experiment:** configure a status line that prints the current clock time, leave a session idle, and observe whether the displayed time advances."* + +**Both halves ran.** The schema, read out of the binary: `statusLine: {type, command, padding?, refreshInterval?}`, where `refreshInterval` is *"Re-run the status line command every N seconds in addition to event-driven updates"* — seconds, minimum 1. ⚠ **The first name-match was `refreshIntervalMs`, which belongs to the certificate watcher.** Reading the context rather than trusting the match is what kept a wrong key out of the design. + +And then the behaviour, from the body's own invocation log on the day it shipped: + +``` +gaps(s): 0,0,0,0,0,0,0,0,0,0,0,0,37,13,0,2,15,2,4,11,13, 60,60,60,60,60,60,60,60,60,60,60,60,60,60, 11,1,1,9,1,3 + └─────────── event-driven ───────────┘ └────────── idle: timer ──────────┘ +``` + +**Fourteen consecutive 60-second gaps with no input at all.** The timer is real and fires during idle. *Documentation is a claim about behaviour; this is the behaviour* — the same standard AMENDMENT 3 set for itself. + +#### (b) ⚠ One clause of the AMENDMENT-2 correction does not survive, and the conclusion outlives its mechanism + +The corrected text reads: *"the 20-minute tick is a counter over refreshes rather than over events… no event-gating remains within a session."* + +**Against `in addition to event-driven updates`, a counter over refreshes IS a counter over events.** The log's leading run of twelve zero-second gaps is exactly what such a counter would have been counting — invocations bursting with activity, which is the v1 defect §8 was rewritten to remove. + +**The conclusion survives; the mechanism named does not.** Frequency is not event-gated — but only because the built tick consults the **clock**, not the invocation count. Recorded here rather than silently implemented, because this item has now logged twice that *a conclusion which keeps its old reasoning after that reasoning is falsified is how a false premise survives its own refutation.* This is the third instance, and it is the item's own text this time. + +#### (c) What shipped, and where the guards actually live + +`6f0ccde` (body) and this session's tier-2 commit. The binding constraints are carried **structurally rather than by intention**, which is the only form that survives a later editor: + +| constraint | how it is held | its negative control | +|---|---|---| +| the body correlates with nothing | `render()` takes the minute and nothing else — a function that cannot see the session cannot leak it; asserted on `co_argcount` and `co_names` | a deliberately leaky fixture **is** caught | +| the body does not announce the voice | `len(MARKS)` coprime with the interval, so the mark at mumble-time walks the whole cycle | a commensurate 4-cycle **is** caught | +| the tick consumes | proven on the built thing: 12/12 forced-due invocations advanced the clock | — | +| the utterance has no adjudication path | the prompt states it verbatim; a mechanical net rejects advice, questions, `we`, vocabulary of lack, and anything outside 3–9 words | every clause has a rejecting fixture | +| the register is not duplicated | the soul is **read from its filed artifact** at run time; no soul, no voice, and no fallback | a copy in the source **is** caught | + +⚠ **One control failed against itself and was fixed rather than relaxed:** the check *"this file contains no copy of the soul"* searched for a phrase that its own needle had put into the file. Same class as the hand-typed link canary whose only finding was the pattern inside its own specification. The needle is now built by concatenation so the literal never appears. + +**Files affected:** `scripts/tarbuckle-body.py`, `scripts/tarbuckle-mumble.py`, `~/.claude/settings.json` (untracked — see below). +**Awaiting:** nothing. Filed as a record at implementation. + ### AMENDMENT 6 — 2026-08-22 — the adjudication-path criterion supersedes "checkable in principle"; the material is the SESSION *Jurist correction, received via the steward. Recorded as a correction rather than as a fifth option.* @@ -4844,12 +4888,14 @@ Link 1 to link 4 directly. **The trial built to test whether a fool could reach *(Filed as a tracked deferral rather than left in prose. §8 already obliges this report and had no trigger — the class PENDING-158 was authorized against today. Deliberately `manual`: inventing a date before the clock can start would be the proxy the schema's own comment warns against.)* diff --git a/scripts/tarbuckle-body.py b/scripts/tarbuckle-body.py index ab43ae6..0e70cf3 100755 --- a/scripts/tarbuckle-body.py +++ b/scripts/tarbuckle-body.py @@ -38,6 +38,7 @@ is filed UNKNOWN — a number derived from an unmeasured base is worse than a bl import json import math import os +import subprocess import sys import time @@ -52,6 +53,19 @@ MARKS = (".", "·", "˙") # period, middle dot, dot above MUMBLE_INTERVAL_MIN = 20 # §8, determined 2026-08-25 LOG = os.path.expanduser("~/.claude/state/tarbuckle-invocations.jsonl") +LAST_TICK = os.path.expanduser("~/.claude/state/tarbuckle-last-tick") +SLOT = os.path.expanduser("~/.claude/state/tarbuckle-slot.json") +MUMBLE = os.path.expanduser("~/dotfiles/scripts/tarbuckle-mumble.py") + +# 73% silent · 20% brief aside · 7% notable — v1 §8's table, hardcoded, unchanged. +# Calibrated against a TIME-UNIFORM tick, which is why the tick had to return to the +# clock before these could mean what they say (PENDING-152). +DRAW = (("silent", 73), ("aside", 20), ("notable", 7)) + +# How long an utterance stays on the surface. Derived from the soul, not chosen: +# "he doesn't wait to see whether it landed; he goes back to whatever he was doing +# with his hands." A line that lingers is a line waiting to be answered. +DISPLAY_WINDOW_S = 120 def render(now_min: int) -> str: @@ -78,6 +92,76 @@ def log_invocation(payload: dict) -> None: pass +def draw() -> str: + """The 73/20/7 draw. Takes nothing, so it can be keyed to nothing. + + SystemRandom rather than a time hash: §8 requires the cadence be UNLEARNABLE, and + anything derived from the clock is predictable by construction. Note this is the + opposite choice from the body's mark, and deliberately — the mark must be + time-derived so it correlates with nothing about the work; the draw must be random + so it cannot be anticipated at all. + """ + import random + r = random.SystemRandom().uniform(0, sum(w for _, w in DRAW)) + upto = 0.0 + for kind, w in DRAW: + upto += w + if r <= upto: + return kind + return "silent" + + +def tick_due(now: float) -> bool: + """Clock, never an invocation counter. See the module docstring for why.""" + try: + last = float(open(LAST_TICK).read().strip()) + except (OSError, ValueError): + _write_tick(now) # first sight: start the clock, do not fire into a wake + return False + return (now - last) >= MUMBLE_INTERVAL_MIN * 60 + + +def _write_tick(now: float) -> None: + try: + os.makedirs(os.path.dirname(LAST_TICK), exist_ok=True) + with open(LAST_TICK, "w") as fh: + fh.write(str(int(now))) + except OSError: + pass + + +def fire_tick(now: float, transcript: str) -> str: + """Consume the draw and, if it speaks, spawn the generator DETACHED. + + ⚠ The tick is consumed whatever the draw says — determination, 2026-08-25: "it + consumes. There is no skip branch, and none should be written." A conserved draw + is a budget, and a budget is memory. + """ + _write_tick(now) + kind = draw() + if kind == "silent" or not transcript: + return kind + try: + subprocess.Popen([sys.executable, MUMBLE, kind, transcript], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, start_new_session=True) + except Exception: + pass + return kind + + +def fresh_utterance(now: float) -> str | None: + """The slot, if it is still warm. One slot, expiring — never a queue.""" + try: + with open(SLOT) as fh: + d = json.load(fh) + if now - float(d["written"]) <= DISPLAY_WINDOW_S: + return str(d["utterance"]) + except Exception: + pass + return None + + def main() -> int: payload = {} try: @@ -89,7 +173,17 @@ def main() -> int: except Exception: payload = {} log_invocation(payload) - print(render(int(time.time() // 60))) + now = time.time() + + # Precautionary guard. A headless `claude -p` was observed NOT to render a status + # line (2026-08-25, zero invocations logged during an 11 s call), so this is not a + # fix for something seen — it is one env check against a fork bomb. + if not os.environ.get("TARBUCKLE_CHILD"): + if tick_due(now): + fire_tick(now, payload.get("transcript_path") or "") + + said = fresh_utterance(now) + print(f"{NAME} {said}" if said else render(int(now // 60))) return 0 @@ -154,6 +248,59 @@ def selftest() -> int: ck("C6 marks are single characters", all(len(m) == 1 for m in MARKS)) ck("C6 marks are distinct", len(set(MARKS)) == len(MARKS)) + # --- D: the tick, the draw, the slot. Run against a temp dir, never live state. + import tempfile, collections + global LAST_TICK, SLOT + _lt, _sl = LAST_TICK, SLOT + td = tempfile.mkdtemp() + LAST_TICK, SLOT = os.path.join(td, "tick"), os.path.join(td, "slot") + try: + # D1 — the draw is keyed to nothing. Structural, like C3. + ck("D1 draw takes no arguments", draw.__code__.co_argcount == 0) + ck("D1 draw yields only declared kinds", + {draw() for _ in range(300)} <= {"silent", "aside", "notable"}) + + # D2 — the proportions are the filed ones. 60k samples, +/- 1.5pp. + c = collections.Counter(draw() for _ in range(60000)) + pct = {k: 100.0 * v / 60000 for k, v in c.items()} + ck("D2 silent ~73%", abs(pct.get("silent", 0) - 73) < 1.5) + ck("D2 aside ~20%", abs(pct.get("aside", 0) - 20) < 1.5) + ck("D2 notable ~7%", abs(pct.get("notable", 0) - 7) < 1.5) + + # D3 — first sight starts the clock and does NOT fire. A fool that fires on + # its first invocation speaks into the wake, where the voice already speaks. + now = 1_000_000.0 + ck("D3 first sight does not fire", tick_due(now) is False) + ck("D3 first sight started the clock", os.path.exists(LAST_TICK)) + + # D4 — the clock governs, in both directions. + ck("D4n not due before the interval", + tick_due(now + MUMBLE_INTERVAL_MIN * 60 - 1) is False) + ck("D4 due at the interval", + tick_due(now + MUMBLE_INTERVAL_MIN * 60) is True) + + # D5 — THE DETERMINATION: the tick consumes whatever the draw says. A silent + # draw that did not advance the clock would be a conserved draw, i.e. a budget. + before = open(LAST_TICK).read() + fire_tick(now + 9999, "") # empty transcript => cannot speak + ck("D5 silent tick still consumes", open(LAST_TICK).read() != before) + + # D6 — the slot expires. One slot, never a queue. + json.dump({"utterance": "Fourth time.", "kind": "aside", + "written": int(now)}, open(SLOT, "w")) + ck("D6 fresh utterance shown", fresh_utterance(now + 1) == "Fourth time.") + ck("D6n stale utterance not shown", + fresh_utterance(now + DISPLAY_WINDOW_S + 1) is None) + ck("D6n absent slot is silence", + (os.remove(SLOT), fresh_utterance(now))[1] is None) + finally: + LAST_TICK, SLOT = _lt, _sl + + # D7 — the recursion guard is present in the path that ticks. + src = open(__file__, encoding="utf-8").read() + ck("D7 child guard gates the tick", "TARBUCKLE_CHILD" in src and + src.index("TARBUCKLE_CHILD") < src.index("tick_due(now)")) + for name in checks: print(f" {'FAIL' if name in failed else 'ok '} {name}") print(f"{len(checks) - len(failed)}/{len(checks)} controls passed") diff --git a/scripts/tarbuckle-mumble.py b/scripts/tarbuckle-mumble.py new file mode 100755 index 0000000..597bb8c --- /dev/null +++ b/scripts/tarbuckle-mumble.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +"""Tarbuckle — the mumble generator. Runs DETACHED; never inside the status line. + +Doctrine: v2 §8 (three tiers), §9 (one line, filed nowhere), and PENDING-152 +AMENDMENT 6, which settles the three-way separation: + + Material — the live session. Register — the soul, §7. Occasion — the refresh tick. + "The model supplies the words, in the soul's register, about the session." + +⚠ THE UTTERANCE MUST HAVE NO TRUTH VALUE. The criterion is NOT "checkable in +principle" — it is whether an ADJUDICATION PATH EXISTS (AMENDMENT 6, superseding the +executor's own earlier test). "PENDING-4 has been open since April" has a path: open +the file. Once a path exists the trio walks it, the fool acquires a truth value, and it +is a checker again — which §2 says means the design has failed. "Scoring without +signal" has no path: it is a gesture at a shape, not a claim. That is the whole safety. + +⚠ THE REGISTER IS NOT DUPLICATED HERE. The soul is read from its filed artifact at +run time. Pasting it into this file would create a parallel version of a governed +record, which is the context-rot failure CLAUDE.md names outright. If the soul cannot +be read, NOTHING IS GENERATED — honest degradation, not a fallback voice. + +Latency measured 2026-08-25: ~11 s for a headless call. That is why this is detached +and why the status line never waits on it. +""" +import json +import os +import re +import subprocess +import sys +import time + +SOUL = os.path.expanduser( + "~/dotfiles/claude/governance/fool/seed/FOOL-SOUL-2026-08-25.md") +SLOT = os.path.expanduser("~/.claude/state/tarbuckle-slot.json") +REJECTS = os.path.expanduser("~/.claude/state/tarbuckle-rejects.jsonl") + +MIN_WORDS, MAX_WORDS = 3, 9 # observed Thistleweld register: three to nine words + +# From the soul's own "What he never does", mechanically enforced. The model is asked +# for the register; this is the net under it. A violation yields SILENCE, never a +# repaired line — rewriting the fool's words would make the executor its editor. +BANNED = ( + r"\bshould\b", r"\btry\b", r"\bmust\b", # advice + r"\bgone\b", r"\bif only\b", r"\bused to be\b", r"\bmissing\b", # vocabulary of lack + r"\bwe\b", # never says 'we' about the work + r"\?", # never asks +) + + +def soul_register() -> str | None: + """The soul, verbatim, from the filed artifact. None if unreadable.""" + try: + body = open(SOUL, encoding="utf-8").read() + except OSError: + return None + m = re.search(r"```markdown\n(.*?)\n```", body, re.S) + return m.group(1) if m else None + + +def session_material(transcript_path: str, budget: int = 6000) -> str: + """The tail of the live session. Bounded, and tool output is dropped. + + Deliberately NOT the docket. AMENDMENT 6: the docket has a forum and the session + does not, which is why the session is safe material and PENDING.md is not. + """ + try: + lines = open(transcript_path, encoding="utf-8", errors="replace").readlines() + except OSError: + return "" + out = [] + for ln in reversed(lines[-400:]): + try: + rec = json.loads(ln) + except Exception: + continue + if rec.get("type") not in ("user", "assistant"): + continue + msg = rec.get("message") or {} + content = msg.get("content") + text = "" + if isinstance(content, str): + text = content + elif isinstance(content, list): + text = " ".join(c.get("text", "") for c in content + if isinstance(c, dict) and c.get("type") == "text") + text = text.strip() + if not text: + continue + out.append(f"{rec['type']}: {text[:600]}") + if sum(len(s) for s in out) > budget: + break + return "\n".join(reversed(out)) + + +def build_prompt(kind: str, register: str, material: str) -> str: + weight = ("Something at the shape of the work, not its detail." + if kind == "notable" else + "An ordinary passing remark. Small.") + return f"""You are writing ONE line as Tarbuckle. His character, filed and unalterable: + +{register} + +Here is the tail of the session he is in the room for: + +{material} + + +{weight} + +Write ONE line in his voice. Absolute constraints: +- Between {MIN_WORDS} and {MAX_WORDS} words. One clause. Present tense. Flat, no lift. +- IT MUST HAVE NO TRUTH VALUE. Nobody must be able to open a file and check it, agree + with it, or refute it. Put two things next to each other so a shape shows. Do not + state a fact about the work, the record, the code, or the docket. +- No advice, no questions, no warning of consequences, no explanation, no second line. +- Never the word 'we'. No vocabulary of lack. +- Do not name files, items, numbers of open things, or anything with an address. + +Output the line and nothing else. No quotes, no preamble.""" + + +def acceptable(line: str) -> tuple[bool, str]: + if not line or "\n" in line.strip(): + return False, "not one line" + n = len(line.split()) + if not (MIN_WORDS <= n <= MAX_WORDS): + return False, f"{n} words" + for pat in BANNED: + if re.search(pat, line, re.I): + return False, f"banned {pat}" + return True, "" + + +def main() -> int: + kind = sys.argv[1] if len(sys.argv) > 1 else "aside" + transcript = sys.argv[2] if len(sys.argv) > 2 else "" + register = soul_register() + if not register: + return 1 # no soul, no voice. Deliberately no fallback. + material = session_material(transcript) + if not material.strip(): + return 1 + env = dict(os.environ, TARBUCKLE_CHILD="1") # precautionary; see body script + try: + r = subprocess.run(["claude", "-p", build_prompt(kind, register, material)], + capture_output=True, text=True, timeout=120, env=env) + except Exception: + return 1 + line = (r.stdout or "").strip().strip('"').strip() + ok, why = acceptable(line) + if not ok: + try: + with open(REJECTS, "a") as fh: + fh.write(json.dumps({"t": time.strftime("%Y-%m-%dT%H:%M:%S%z"), + "kind": kind, "why": why, "line": line[:200]}) + "\n") + except Exception: + pass + return 1 # silence. The draw was already consumed. + try: + os.makedirs(os.path.dirname(SLOT), exist_ok=True) + with open(SLOT, "w") as fh: + json.dump({"utterance": line, "kind": kind, "written": int(time.time())}, fh) + except Exception: + return 1 + return 0 + + +def selftest() -> int: + checks, failed = [], [] + + def ck(name, cond): + checks.append(name) + if not cond: + failed.append(name) + + # A1 — the register is READ, not duplicated. One canonical source. + reg = soul_register() + ck("A1 soul readable from its filed artifact", bool(reg)) + ck("A1 soul is the real thing", bool(reg) and "Tarbuckle" in reg and "SUCCESSION" in reg) + # ⚠ The needle is BUILT rather than written, because the first version of this + # control failed against itself: the literal phrase it searched for was placed in + # the file BY the search. Same class as the hand-typed link canary whose only + # finding was the pattern inside its own specification. + needle = "registrar rather than " + "a guardian" + ck("A1n this file does not contain a copy of the soul", + needle not in open(__file__, encoding="utf-8").read()) + + # A2 — the acceptability net. Positive AND negative controls on every clause. + ck("A2 accepts an in-register line", acceptable("Fourth time. First one was better.")[0]) + ck("A2 accepts a short collision", acceptable("Two names, one thing.")[0]) + ck("A2n rejects too few words", not acceptable("Yes.")[0]) + ck("A2n rejects too many words", + not acceptable(" ".join(["word"] * (MAX_WORDS + 1)))[0]) + ck("A2n rejects advice", not acceptable("You should check that again now.")[0]) + ck("A2n rejects a question", not acceptable("How is that going for you?")[0]) + ck("A2n rejects vocabulary of lack", not acceptable("The third one is missing now.")[0]) + ck("A2n rejects 'we'", not acceptable("We did the second part first.")[0]) + ck("A2n rejects two lines", not acceptable("First line here.\nSecond line here.")[0]) + + # A3 — the prompt carries the no-truth-value constraint verbatim, not by intention. + p = build_prompt("aside", "SOUL", "MATERIAL") + ck("A3 prompt states no truth value", "NO TRUTH VALUE" in p) + ck("A3 prompt forbids addresses", "anything with an address" in p) + ck("A3 prompt embeds the register", "SOUL" in p) + ck("A3 notable differs from aside", + build_prompt("notable", "S", "M") != build_prompt("aside", "S", "M")) + + # A4 — material is the session, never the docket. + # Structural, not textual: the module's file constants are the only things it + # opens, so assert none of them addresses the docket. + paths = (SOUL, SLOT, REJECTS) + ck("A4 material paths are session/soul only", + not any(("PENDING" in q or "REVIEWED" in q) for q in paths)) + ck("A4n the predicate can fail", + any(("PENDING" in q) for q in paths + ("/x/PENDING.md",))) + + for name in checks: + print(f" {'FAIL' if name in failed else 'ok '} {name}") + print(f"{len(checks) - len(failed)}/{len(checks)} controls passed") + if failed: + print("INSTRUMENT NOT VERIFIED") + return 1 + return 0 + + +if __name__ == "__main__": + if "--selftest" in sys.argv: + sys.exit(selftest()) + sys.exit(main())