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: