diff --git a/scripts/tarbuckle-body.py b/scripts/tarbuckle-body.py index fa64526..86510a6 100755 --- a/scripts/tarbuckle-body.py +++ b/scripts/tarbuckle-body.py @@ -92,6 +92,18 @@ def log_invocation(payload: dict) -> None: pass +def _log_draw(kind: str) -> None: + """Occurrence only. Local, so the body needs nothing importable to tick.""" + try: + d = os.path.expanduser("~/.claude/state/tarbuckle-draws.jsonl") + os.makedirs(os.path.dirname(d), exist_ok=True) + with open(d, "a") as fh: + fh.write(json.dumps({"t": time.strftime("%Y-%m-%dT%H:%M:%S%z"), + "surface": "tick", "outcome": kind}) + "\n") + except Exception: + pass + + def _muted() -> str: """Local, not imported: the body must not depend on the generator being importable.""" try: @@ -148,6 +160,7 @@ def fire_tick(now: float, transcript: str) -> str: """ _write_tick(now) kind = draw() + _log_draw(kind) # the 73% is the denominator; unrecorded, no rate exists if kind == "silent" or not transcript: return kind try: diff --git a/scripts/tarbuckle-invoke.py b/scripts/tarbuckle-invoke.py index 46cb02f..012a29d 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, muted, REJECTS) + source_lacks, muted, log_event, REJECTS) MAX_WORDS_INVOKED = 180 # §9's "at length", made a number TIMEOUT_S = 90 # the steward is deliberately waiting; he may take longer @@ -109,6 +109,7 @@ def main() -> int: capture_output=True, text=True, timeout=TIMEOUT_S, env=env) except subprocess.TimeoutExpired: log_silence(f"invoked generation exceeded {TIMEOUT_S}s", "") + log_event("invoke", "silent") return 0 except Exception: return 1 @@ -116,6 +117,7 @@ def main() -> int: ok, why = acceptable(line, max_words=MAX_WORDS_INVOKED, one_line=False) if not ok: log_silence(why, line) + log_event("invoke", "silent") # ⚠ THE LINE IS STILL WITHHELD — silence-on-violation is not relaxed, and the # rejected text is never printed. But the INSTRUMENT reports its own state. # @@ -130,6 +132,7 @@ def main() -> int: print(f"(called; nothing passed the net — {why}. logged, not shown.)", file=sys.stderr) return 0 + log_event("invoke", "spoke") print(line) return 0 diff --git a/scripts/tarbuckle-mumble.py b/scripts/tarbuckle-mumble.py index 53f08ff..a36d08f 100755 --- a/scripts/tarbuckle-mumble.py +++ b/scripts/tarbuckle-mumble.py @@ -48,6 +48,31 @@ BANNED = ( MUTE = os.path.expanduser("~/.claude/state/tarbuckle-mute") +DRAWS = os.path.expanduser("~/.claude/state/tarbuckle-draws.jsonl") + + +def log_event(surface: str, outcome: str) -> None: + """Count occurrences. NEVER content. §8 obliges a rate; §9 forbids a log. + + ⚠ The two clauses look like they collide and do not, on this reading: §9's "filed + nowhere — no PENDING entry, no log, no item" is about the fool's OUTPUT entering the + record, and §8 explicitly orders "report the observed mumble rate after two weeks." + A rate needs a denominator. So this records THAT something happened and never WHAT + was said — occurrence, not utterance. + + ⚠ The rejections log is a different case and is NOT settled: it holds up to 200 + characters of his words, at the steward's instruction, and that is nearer to filing. + Flagged rather than resolved; see the note put to the steward 2026-08-25. + + Nothing reads this back. It is measurement, not memory. + """ + try: + os.makedirs(os.path.dirname(DRAWS), exist_ok=True) + with open(DRAWS, "a") as fh: + fh.write(json.dumps({"t": time.strftime("%Y-%m-%dT%H:%M:%S%z"), + "surface": surface, "outcome": outcome}) + "\n") + except Exception: + pass def muted() -> str: @@ -199,6 +224,7 @@ def main() -> int: "kind": kind, "why": why, "line": line[:200]}) + "\n") except Exception: pass + log_event(kind, "rejected") return 1 # silence. The draw was already consumed. try: os.makedirs(os.path.dirname(SLOT), exist_ok=True) @@ -206,6 +232,7 @@ def main() -> int: json.dump({"utterance": line, "kind": kind, "written": int(time.time())}, fh) except Exception: return 1 + log_event(kind, "spoke") return 0 @@ -278,6 +305,12 @@ def selftest() -> int: ck("A5n a junk value is not a mute", muted() == "") finally: MUTE = _m + # A6 — the occurrence log is content-free, and that is the whole point. + ck("A6 log_event takes no content", + log_event.__code__.co_argcount == 2 + and set(log_event.__code__.co_varnames[:2]) == {"surface", "outcome"}) + ck("A6n it cannot be handed a line", + "line" not in log_event.__code__.co_varnames) 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 c7538a2..920bdcb 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, muted, REJECTS) + source_lacks, muted, log_event, REJECTS) LAST_TICK = os.path.expanduser("~/.claude/state/tarbuckle-last-tick") SEAM_TIMEOUT_S = 15 @@ -123,6 +123,7 @@ def main() -> int: timeout=SEAM_TIMEOUT_S, env=env) except subprocess.TimeoutExpired: log_silence(f"seam generation exceeded {SEAM_TIMEOUT_S}s") + log_event("seam", "silent") return 0 # the occasion was guaranteed; the utterance is not except Exception: return 0 @@ -130,7 +131,9 @@ def main() -> int: ok, why = acceptable(line) if not ok: log_silence(why, line) + log_event("seam", "silent") return 0 + log_event("seam", "spoke") print(f"Tarbuckle {line}") return 0 diff --git a/scripts/tarbuckle-wrap.py b/scripts/tarbuckle-wrap.py index 8a6cb48..bafdb56 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, muted, REJECTS) + source_lacks, muted, log_event, REJECTS) FIRED = os.path.expanduser("~/.claude/state/tarbuckle-wrap-fired") TIMEOUT_S = 15 @@ -163,6 +163,7 @@ def main() -> int: capture_output=True, text=True, timeout=TIMEOUT_S, env=env) except subprocess.TimeoutExpired: log_silence(f"wrap generation exceeded {TIMEOUT_S}s") + log_event("wrap", "silent") return 0 except Exception: return 0 @@ -170,7 +171,9 @@ def main() -> int: ok, why = acceptable(line) if not ok: log_silence(why, line) + log_event("wrap", "silent") return 0 + log_event("wrap", "spoke") print(json.dumps({"systemMessage": f"Tarbuckle {line}"})) return 0