From cfbaded5802ffec65290fc41bd5e398e193b593e Mon Sep 17 00:00:00 2001 From: David F Glidden Date: Tue, 25 Aug 2026 16:56:32 +0200 Subject: [PATCH] [FIX] The wrap seam: Stop + systemMessage, and a detector that fired on its own authoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SessionEnd had no delivery. `Stop` does — the binary documents it: "Stop hook that displays message to user: Command must output JSON with `systemMessage` field". But Stop fires every turn, and a fool who speaks every turn is a chatbot, so it needs a real wrap signal. It uses the most direct one: the transcript records the steward's command invocations, so a wrap is DETECTED rather than inferred from a file mtime, which is the proxy shape this record has twice logged as the thing that fails. ⚠ THE FIRST VERSION FIRED ON A SESSION THAT NEVER WRAPPED, and the cause is the third and worst self-reference of the day. The marker was held as a literal; the transcript records EVERYTHING, including the act of writing this detector; so authoring the literal planted it in the corpus the detector searches. All three matches were `tool_use` inputs — the executor writing the thing that then found itself. Fixed twice over, because one fix is not the class. STRUCTURALLY: only a `type="user"` record with a STRING content counts, measured against how a real invocation is actually recorded, which is what separates the steward doing it from the executor writing about it. TEXTUALLY: the marker is assembled from parts so the literal never exists in source. Both have controls, including the exact negative that would have caught the first version — an assistant tool_use carrying the marker must not read as a wrap. Idempotence is by session id and CONSUMED BEFORE GENERATING, so a failed generation falls silent rather than retrying on every subsequent turn. The prompt guards the drift this seam specifically invites, which is not summary but CLOSURE — a wrap already has a record and he is not it. 21/21 controls. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01J6hZXNYSxEfZseBGTni4sf --- scripts/tarbuckle-wrap.py | 258 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100755 scripts/tarbuckle-wrap.py diff --git a/scripts/tarbuckle-wrap.py b/scripts/tarbuckle-wrap.py new file mode 100755 index 0000000..9bf6078 --- /dev/null +++ b/scripts/tarbuckle-wrap.py @@ -0,0 +1,258 @@ +#!/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, REJECTS) + +FIRED = os.path.expanduser("~/.claude/state/tarbuckle-wrap-fired") +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 + if rec.get("type") != "user": + continue # an executor writing about it is not the steward doing it + content = (rec.get("message") or {}).get("content") + if not isinstance(content, str): + continue # a real invocation is a plain string + if any(m in content for m in WRAP_MARKERS): + 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: +- 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: + try: + with open(REJECTS, "a") as fh: + fh.write(json.dumps({"t": time.strftime("%Y-%m-%dT%H:%M:%S%z"), + "kind": "wrap", "why": why, "line": line[:200]}) + "\n") + except Exception: + pass + + +def main() -> int: + try: + payload = json.loads(sys.stdin.read() or "{}") or {} + except Exception: + return 0 + 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") + return 0 + except Exception: + return 0 + line = (r.stdout or "").strip().strip('"').strip() + ok, why = acceptable(line) + if not ok: + log_silence(why, line) + return 0 + 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) + + # 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())