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())