[FIX] The wrap seam: Stop + systemMessage, and a detector that fired on its own authoring
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J6hZXNYSxEfZseBGTni4sf
This commit is contained in:
co-authored by
Claude Opus 5
parent
8a0e5c448d
commit
cfbaded580
Executable
+258
@@ -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 = "<command-" + "name>"
|
||||
_CNE = "</command-" + "name>"
|
||||
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:
|
||||
<session>
|
||||
{material}
|
||||
</session>
|
||||
|
||||
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": "<command-message>wrap-up</command-message> " + 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__, "<command-", "name>/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())
|
||||
Reference in New Issue
Block a user