#!/usr/bin/env python3 """Tarbuckle — the wrap seam. v2 §8's second seam, and the one that had no surface. ⚠ WHY THIS IS A `Stop` HOOK AND NOT `SessionEnd`. SessionEnd's handler writes output only when a hook FAILS; a successful hook's stdout goes nowhere, so a wrap seam wired there would fire into nothing and report success. `Stop` goes through the general hook pipeline, and the binary's own documentation says how to reach the steward from it: "Stop hook that displays message to user: Command must output JSON with `systemMessage` field" ⚠ BUT `Stop` FIRES ON EVERY TURN, and a fool who speaks every turn is not a fool, he is a chatbot. §8 gives the voice ~2-3 utterances a day, not thirty. So this needs a real wrap signal, and it uses the most direct one available: the transcript records the steward's command invocations, so the wrap is detected by finding `/wrap-up` actually invoked in this session — not inferred from a file's mtime, which is a proxy and would fire on any session-file write. Idempotence is by session id, in a marker file. That is not a budget and not memory in §8's sense: it conserves no draw and takes no input from whether the fool was heard. It answers exactly one question — has he already spoken at this session's wrap. ⚠ HE MAY FALL SILENT HERE TOO. Steward's ruling: a guaranteed occasion is not a guaranteed utterance. Timeout or a failed net both yield silence and a logged reason. """ import json import os import subprocess import sys 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, log_event, # noqa: E402 echoes_soul, log_rejection, REJECTS) FIRED = os.path.expanduser("~/.claude/state/tarbuckle-wrap-fired") # ⚠ ONE TIMESTAMP, OVERWRITTEN — not appended. The silent-net objection against this hook # was that it writes nothing on an ordinary turn, so there is no evidence it is invoked at # all. A heartbeat answers that; an append-only log of every turn would be noise and would # become the ledger §9 forbids. Overwrite is the proportionate form: it proves liveness and # remembers nothing. HEARTBEAT = os.path.expanduser("~/.claude/state/tarbuckle-wrap-lastrun") TIMEOUT_S = 15 TAIL_LINES = 400 # bounded: this runs on EVERY assistant turn # ⚠ ASSEMBLED, NEVER WRITTEN WHOLE — and this is not fastidiousness, it is a bug fix. # The first version held the marker as a literal, and fired on a session in which the # marker had never been invoked: the transcript records EVERYTHING, including the act of # writing this detector, so authoring the literal planted it in the corpus the detector # searches. Third self-reference of the day, and the worst of the three, because the # corpus is live and records its own instrumentation being built. _CN = "" _CNE = "" WRAP_MARKERS = (_CN + "/wrap-up" + _CNE, _CN + "wrap-up" + _CNE) def wrap_invoked(transcript_path: str) -> bool: """True if THIS session actually ran /wrap-up. ⚠ STRUCTURAL, NOT TEXTUAL. Only a USER record whose content is a string counts. A substring search over the raw blob matched `tool_use` inputs — i.e. the executor writing about the marker — which is how the first version fired on a session that never wrapped. Measured against the real record: an invocation is `type="user"` with a string content; an executor mentioning it is `type="assistant"` with a list of content blocks. The type check is what separates them. The rejected alternative — 'a session-*.md was written recently' — is a proxy, and this record has logged twice that a proxy is what fails. """ try: with open(transcript_path, encoding="utf-8", errors="replace") as fh: lines = fh.readlines()[-TAIL_LINES:] except OSError: return False for ln in lines: if "wrap-up" not in ln: continue try: rec = json.loads(ln) except Exception: continue content = (rec.get("message") or {}).get("content") # (a) the steward TYPES /wrap-up -> a user record whose content is a plain string if rec.get("type") == "user" and isinstance(content, str): if any(m in content for m in WRAP_MARKERS): return True # (b) the steward says "wrap" in prose and the EXECUTOR invokes the skill. # # ⚠ THIS BRANCH IS THE WHOLE BUG, FOUND BY THE WRAP IT WAS BUILT FOR. The first # version had only (a), and on the day it shipped the steward wrote "then wrap" # and the executor called the Skill tool: zero user-typed records, 29 assistant # invocations, detector correctly returns False, fool silent. The detector was # not broken — what it was built to detect is not how a wrap actually arrives. # # ⚠ AND THE FIX THAT MADE IT CORRECT IS WHAT BLINDED IT. Restricting to user # records was the right answer to the self-reference bug (the executor's own # tool_use inputs matched the literal). The same restriction excludes the real # path. Recorded because "the correct fix caused the next failure" is not a # shape the controls can see; it is PENDING-160's subject exactly. # # Structural, not textual: a `tool_use` block whose NAME is Skill and whose # input names the wrap-up skill. A Bash command that merely echoes the string # has name="Bash" and does not match. if rec.get("type") == "assistant" and isinstance(content, list): for blk in content: if not isinstance(blk, dict) or blk.get("type") != "tool_use": continue if blk.get("name") != "Skill": continue if "wrap-up" in str(blk.get("input") or {}): return True return False def already_fired(session_id: str) -> bool: try: return session_id and session_id in open(FIRED).read().split() except OSError: return False def mark_fired(session_id: str) -> None: try: os.makedirs(os.path.dirname(FIRED), exist_ok=True) prior = "" try: prior = open(FIRED).read().split()[-40:] # bounded, not a ledger prior = " ".join(prior) except OSError: pass with open(FIRED, "w") as fh: fh.write((prior + " " + session_id).strip()) except OSError: pass def build_prompt(register: str, material: str) -> str: return f"""You are writing ONE line as Tarbuckle. His character, filed and unalterable: {register} The work he has been in the room for is being put down for the day: {material} Write ONE line in his voice, as it is being put down. Absolute constraints: - ⚠ THE SAMPLE LINES ABOVE ARE ILLUSTRATIONS OF REGISTER, NOT VOCABULARY. Do not reuse them, any phrase from them, or their subject matter. What he says must come from the session above. If the line would suit any other session equally, it is wrong. - ⚠ THIS IS MECHANICALLY ENFORCED, NOT ADVISORY: any FOUR consecutive words you share with a sample line are detected and the whole line is discarded. You are then silent. Nothing is rewritten for you and nothing is retried. - Between 3 and 9 words. One clause. Present tense. Flat, no lift. - IT MUST HAVE NO TRUTH VALUE. Nobody may 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 SUMMARISE AND DO NOT CLOSE. No verdict on the day, no tally, no sending-off. A wrap already has a record; he is not it. He noticed one thing as it was put down. - No advice, no questions, no warning of consequences, no explanation, no second line. - Never the word 'we'. No vocabulary of lack. Nothing with an address. Output the line and nothing else. No quotes, no preamble.""" def log_silence(why: str, line: str = "") -> None: """Routed through the canonical writer, which cannot record an accepted line.""" log_rejection(why, line, "wrap") def main() -> int: if muted(): return 0 try: payload = json.loads(sys.stdin.read() or "{}") or {} except Exception: return 0 try: os.makedirs(os.path.dirname(HEARTBEAT), exist_ok=True) with open(HEARTBEAT, "w") as fh: fh.write(time.strftime("%Y-%m-%dT%H:%M:%S%z")) except OSError: pass session_id = str(payload.get("session_id") or "") transcript = payload.get("transcript_path") or "" if not transcript or not wrap_invoked(transcript): return 0 # ordinary turn: nothing, silently if already_fired(session_id): return 0 # he does not repeat himself register = soul_register() if not register: return 0 material = session_material(transcript) if not material.strip(): return 0 mark_fired(session_id) # consume BEFORE speaking: a failed # generation must not retry next turn env = dict(os.environ, TARBUCKLE_CHILD="1") try: r = subprocess.run(["claude", "-p", build_prompt(register, material)], 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 line = (r.stdout or "").strip().strip('"').strip() ok, why = acceptable(line) if ok and echoes_soul(line, register): ok, why = False, f"recited the soul: {echoes_soul(line, register)!r}" 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 def selftest() -> int: checks, failed = [], [] import tempfile def ck(name, cond): checks.append(name) if not cond: failed.append(name) src = open(__file__, encoding="utf-8").read() # W1 — the net is imported and unchanged. Ordinary case: still one line, 3-9 words. ck("W1 net imported, not redefined", source_lacks(__file__, "def ", "acceptable(")) ck("W1n the predicate can fail", not source_lacks(__file__, "def ", "main(")) ck("W1 ordinary net still applies here", not acceptable(" ".join(["word"] * 12))[0] and acceptable("Second part first.")[0]) # W2 — DELIVERY. The binary requires JSON with systemMessage; stdout alone is lost. ck("W2 emits systemMessage json", '"systemMessage"' in src) ck("W2 emits nothing on an ordinary turn", "if not transcript or not wrap_invoked(transcript):" in src) # W3 — the wrap signal is the real one, not a proxy. td = tempfile.mkdtemp() t = os.path.join(td, "t.jsonl") open(t, "w").write(json.dumps({"type": "user", "message": {"content": "wrap-up " + WRAP_MARKERS[0]}}) + "\n") ck("W3 detects an invoked wrap", wrap_invoked(t) is True) open(t, "w").write(json.dumps({"type": "user", "message": {"content": "just an ordinary turn"}}) + "\n") ck("W3n does not fire on an ordinary turn", wrap_invoked(t) is False) open(t, "w").write(json.dumps({"type": "user", "message": {"content": "the steward mentioned wrap-up in passing"}}) + "\n") ck("W3n bare mention is not an invocation", wrap_invoked(t) is False) # ⚠ THE CONTROL THAT WOULD HAVE CAUGHT THE FIRST VERSION. An assistant tool_use # containing the marker is the executor WRITING this detector, not a wrap. open(t, "w").write(json.dumps({"type": "assistant", "message": {"content": [ {"type": "tool_use", "name": "Bash", "input": {"command": "echo " + WRAP_MARKERS[0]}}]}}) + "\n") ck("W3n executor writing the marker is NOT a wrap", wrap_invoked(t) is False) open(t, "w").write(json.dumps({"type": "user", "message": {"content": [{"type": "text", "text": WRAP_MARKERS[0]}]}}) + "\n") ck("W3n non-string user content is not an invocation", wrap_invoked(t) is False) ck("W3 the marker is assembled, never literal in source", source_lacks(__file__, "/wrap-up")) ck("W3n missing transcript is not a wrap", wrap_invoked(os.path.join(td, "nope")) is False) # W3b — THE REAL PATH: steward says "wrap" in prose, executor invokes the skill. open(t, "w").write(json.dumps({"type": "assistant", "message": {"content": [ {"type": "tool_use", "name": "Skill", "input": {"skill": "wrap-up"}}]}}) + "\n") ck("W3b executor Skill invocation IS a wrap", wrap_invoked(t) is True) # and it must still exclude the executor merely writing about it open(t, "w").write(json.dumps({"type": "assistant", "message": {"content": [ {"type": "tool_use", "name": "Bash", "input": {"command": "echo wrap-up " + WRAP_MARKERS[0]}}]}}) + "\n") ck("W3bn a Bash echo of the marker is NOT a wrap", wrap_invoked(t) is False) open(t, "w").write(json.dumps({"type": "assistant", "message": {"content": [ {"type": "tool_use", "name": "Skill", "input": {"skill": "wake-up"}}]}}) + "\n") ck("W3bn a different skill is NOT a wrap", wrap_invoked(t) is False) ck("W4 heartbeat is overwritten, never appended", 'open(HEARTBEAT, "w")' in src) # W4 — idempotence, and consumption BEFORE the attempt. global FIRED _f = FIRED FIRED = os.path.join(td, "fired") try: ck("W4n unfired session is not marked", already_fired("abc") is False) mark_fired("abc") ck("W4 fired session is marked", already_fired("abc") is True) ck("W4 a different session is unaffected", already_fired("xyz") is False) mark_fired("xyz") ck("W4 both are held", already_fired("abc") and already_fired("xyz")) ck("W4 marker is bounded, not a ledger", "[-40:]" in src) ck("W4 consumed before generating", src.index("mark_fired(session_id) ") < src.index('subprocess.run(["claude"')) finally: FIRED = _f # W5 — the drift this seam invites is closure, and the prompt names it. p = build_prompt("SOUL", "MATERIAL") ck("W5 prompt forbids summary and verdict", "DO NOT SUMMARISE AND DO NOT CLOSE" in p) ck("W5 prompt keeps no truth value", "NO TRUTH VALUE" in p) ck("W5n wrap prompt differs from the wake prompt", "put down" in p) 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())