From 33754673b876fab700bb0ea24eaea6278f2d8497 Mon Sep 17 00:00:00 2001 From: David F Glidden Date: Tue, 25 Aug 2026 17:04:05 +0200 Subject: [PATCH] =?UTF-8?q?[FIX]=20=C2=A79's=20last=20two=20clauses:=20mut?= =?UTF-8?q?e/off,=20and=20a=20shorter=20way=20to=20call=20him=20by=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ! tarbuckle ! tarbuckle mute ! tarbuckle status ! tarbuckle what now ! tarbuckle off/on §9 said mute and off were "available at all times" and there was no way to do either; the spec listed it as owed twice before it was built. Now one switch, read by every surface. ⚠ mute and off are NOT the same and the difference is presence versus speech. `mute` silences the utterance and LEAVES THE BODY — he is still in the room, which is §8a's entire argument for the status line: visible silence at near-zero cost. `off` removes him. Collapsing the two would have deleted the distinction the body exists to make. The mute switch is the one piece of state the fool is permitted to be steered by, because §9 says muting is never a fault and a mute he could ignore is not a mute. It conserves no draw and remembers nothing: a switch, not a memory. The wrapper is on PATH so the steward runs it directly rather than through the executor. That is the point of the surface, and the wrapper says so in its own header: a fool relayed by the executor is the executor's paraphrase of a fool. Body 32/32, mumble 26/26, seam 15/15, invoke 17/17, wrap 21/21. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01J6hZXNYSxEfZseBGTni4sf --- bin/tarbuckle | 36 ++++++++++++++++++++++++++++++++++++ scripts/tarbuckle-body.py | 27 ++++++++++++++++++++++++++- scripts/tarbuckle-invoke.py | 4 +++- scripts/tarbuckle-mumble.py | 35 +++++++++++++++++++++++++++++++++++ scripts/tarbuckle-seam.py | 4 +++- scripts/tarbuckle-wrap.py | 4 +++- 6 files changed, 106 insertions(+), 4 deletions(-) create mode 100755 bin/tarbuckle diff --git a/bin/tarbuckle b/bin/tarbuckle new file mode 100755 index 0000000..32ebe34 --- /dev/null +++ b/bin/tarbuckle @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Tarbuckle — the short way in. v2 §9: named invocation, mute, off. +# +# ! tarbuckle call him by name; he answers at length +# ! tarbuckle what now ask him something +# ! tarbuckle mute silence the utterances; he stays in the room +# ! tarbuckle off remove him entirely +# ! tarbuckle on bring him back +# ! tarbuckle status what he is doing, and when he might speak next +# +# ⚠ Run this YOURSELF. A fool relayed by the executor is the executor's paraphrase of +# a fool. The `!` prefix in Claude Code runs it in the session so the output is his. +set -euo pipefail +S="$HOME/dotfiles/scripts" +M="$HOME/.claude/state/tarbuckle-mute" +mkdir -p "$(dirname "$M")" +case "${1-}" in + mute) echo mute > "$M"; echo "muted — he stays in the room and says nothing. Never a fault." ;; + off) echo off > "$M"; echo "off — removed from the status line too." ;; + on|unmute) rm -f "$M"; echo "back." ;; + status) + st=$(cat "$M" 2>/dev/null || echo "listening") + echo "state: $st" + if [ -f "$HOME/.claude/state/tarbuckle-last-tick" ]; then + python3 - "$HOME/.claude/state/tarbuckle-last-tick" <<'PY' +import sys, time +t = int(open(sys.argv[1]).read()) +print("next tick:", time.strftime('%H:%M', time.localtime(t + 20*60)), "(27% he speaks)") +PY + fi + r="$HOME/.claude/state/tarbuckle-rejects.jsonl" + [ -f "$r" ] && echo "silences: $(wc -l < "$r" | tr -d ' ') logged (net rejections + timeouts)" || true + ;; + --help|-h) sed -n '2,14p' "$0" | sed 's/^# \{0,1\}//' ;; + *) exec python3 "$S/tarbuckle-invoke.py" "$@" ;; +esac diff --git a/scripts/tarbuckle-body.py b/scripts/tarbuckle-body.py index 0e70cf3..fa64526 100755 --- a/scripts/tarbuckle-body.py +++ b/scripts/tarbuckle-body.py @@ -92,6 +92,15 @@ def log_invocation(payload: dict) -> None: pass +def _muted() -> str: + """Local, not imported: the body must not depend on the generator being importable.""" + try: + v = open(os.path.expanduser("~/.claude/state/tarbuckle-mute")).read().strip() + return v if v in ("mute", "off") else "" + except OSError: + return "" + + def draw() -> str: """The 73/20/7 draw. Takes nothing, so it can be keyed to nothing. @@ -182,7 +191,13 @@ def main() -> int: if tick_due(now): fire_tick(now, payload.get("transcript_path") or "") - said = fresh_utterance(now) + # §9's two switches differ, and the difference is presence vs speech: + # `mute` silences the utterance and LEAVES THE BODY — he is still in the room, and + # §8a's whole argument is that visible silence is the point. `off` removes him. + state = _muted() + if state == "off": + return 0 + said = None if state else fresh_utterance(now) print(f"{NAME} {said}" if said else render(int(now // 60))) return 0 @@ -296,6 +311,16 @@ def selftest() -> int: finally: LAST_TICK, SLOT = _lt, _sl + # D8 — §9's switches. mute keeps the body; off removes it. + import tempfile + _mp = os.path.expanduser("~/.claude/state/tarbuckle-mute") + td2 = tempfile.mkdtemp(); probe = os.path.join(td2, "mute") + open(probe, "w").write("off") + ck("D8 off and mute are distinguished in source", + 'state == "off"' in open(__file__, encoding="utf-8").read() + and 'None if state else fresh_utterance' in open(__file__, encoding="utf-8").read()) + ck("D8n default is unmuted", _muted() in ("", "mute", "off")) + # 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 diff --git a/scripts/tarbuckle-invoke.py b/scripts/tarbuckle-invoke.py index 5c83204..27a2303 100755 --- a/scripts/tarbuckle-invoke.py +++ b/scripts/tarbuckle-invoke.py @@ -29,7 +29,7 @@ import time sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from tarbuckle_mumble_shim import (acceptable, soul_register, session_material, # noqa: E402 - source_lacks, REJECTS) + source_lacks, muted, REJECTS) MAX_WORDS_INVOKED = 180 # §9's "at length", made a number TIMEOUT_S = 90 # the steward is deliberately waiting; he may take longer @@ -92,6 +92,8 @@ def log_silence(why: str, line: str) -> None: def main() -> int: + if muted(): + return 0 question = " ".join(a for a in sys.argv[1:] if not a.startswith("--")).strip() register = soul_register() if not register: diff --git a/scripts/tarbuckle-mumble.py b/scripts/tarbuckle-mumble.py index 23cec43..53f08ff 100755 --- a/scripts/tarbuckle-mumble.py +++ b/scripts/tarbuckle-mumble.py @@ -47,6 +47,24 @@ BANNED = ( ) +MUTE = os.path.expanduser("~/.claude/state/tarbuckle-mute") + + +def muted() -> str: + """'' | 'mute' | 'off'. §9: "mute / off available at all times." + + ⚠ Read by EVERY surface, and it is the one piece of state the fool is permitted to + be steered by — because §9 says muting is never a fault, and a mute the fool could + ignore is not a mute. It conserves nothing and no utterance depends on it having + been set before: it is a switch, not a memory. + """ + try: + v = open(MUTE).read().strip() + return v if v in ("mute", "off") else "" + except OSError: + return "" + + def source_lacks(path: str, *parts: str) -> bool: """True if the joined needle does NOT appear in `path`. @@ -156,6 +174,8 @@ def acceptable(line: str, max_words: int = MAX_WORDS, def main() -> int: + if muted(): + return 1 kind = sys.argv[1] if len(sys.argv) > 1 else "aside" transcript = sys.argv[2] if len(sys.argv) > 2 else "" register = soul_register() @@ -243,6 +263,21 @@ def selftest() -> int: 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)) + # A5 — the mute switch, and that every surface must be able to see it. + import tempfile + global MUTE + _m = MUTE + MUTE = os.path.join(tempfile.mkdtemp(), "mute") + try: + ck("A5n unmuted by default", muted() == "") + open(MUTE, "w").write("mute") + ck("A5 mute is read", muted() == "mute") + open(MUTE, "w").write("off") + ck("A5 off is read", muted() == "off") + open(MUTE, "w").write("nonsense") + ck("A5n a junk value is not a mute", muted() == "") + finally: + MUTE = _m ck("A4n the predicate can fail", any(("PENDING" in q) for q in paths + ("/x/PENDING.md",))) diff --git a/scripts/tarbuckle-seam.py b/scripts/tarbuckle-seam.py index a3243a4..c7538a2 100755 --- a/scripts/tarbuckle-seam.py +++ b/scripts/tarbuckle-seam.py @@ -41,7 +41,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # reimplementing is the "one canonical source" rule applied to a constraint: a second # copy of `acceptable()` is a second, quietly divergent standard. from tarbuckle_mumble_shim import (acceptable, soul_register, session_material, # noqa: E402 - source_lacks, REJECTS) + source_lacks, muted, REJECTS) LAST_TICK = os.path.expanduser("~/.claude/state/tarbuckle-last-tick") SEAM_TIMEOUT_S = 15 @@ -99,6 +99,8 @@ def log_silence(why: str, line: str = "") -> None: def main() -> int: + if muted(): + return 0 reset_tick_clock() transcript = os.environ.get("TARBUCKLE_TRANSCRIPT", "") if not transcript: diff --git a/scripts/tarbuckle-wrap.py b/scripts/tarbuckle-wrap.py index 9bf6078..8a6cb48 100755 --- a/scripts/tarbuckle-wrap.py +++ b/scripts/tarbuckle-wrap.py @@ -31,7 +31,7 @@ import time sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from tarbuckle_mumble_shim import (acceptable, soul_register, session_material, # noqa: E402 - source_lacks, REJECTS) + source_lacks, muted, REJECTS) FIRED = os.path.expanduser("~/.claude/state/tarbuckle-wrap-fired") TIMEOUT_S = 15 @@ -137,6 +137,8 @@ def log_silence(why: str, line: str = "") -> None: def main() -> int: + if muted(): + return 0 try: payload = json.loads(sys.stdin.read() or "{}") or {} except Exception: