diff --git a/claude/governance/fool/TARBUCKLE-SPEC-13.1-2026-08-25.md b/claude/governance/fool/TARBUCKLE-SPEC-13.1-2026-08-25.md index a7e6e41..ae970fe 100644 --- a/claude/governance/fool/TARBUCKLE-SPEC-13.1-2026-08-25.md +++ b/claude/governance/fool/TARBUCKLE-SPEC-13.1-2026-08-25.md @@ -88,6 +88,18 @@ that fires into nothing and reports success. **Owed, not dropped.** The remainin the `/wrap-up` skill itself, which is a skill change and therefore goes through the skill-harvest register rather than being taken unilaterally. +⚠ **THE WRAP SEAM DID NOT FIRE ON ITS FIRST REAL WRAP, and the reason is the sharpest +finding in PENDING-160.** It detected the steward *typing* `/wrap-up`. On the day it +shipped the steward wrote **"then wrap"** in prose and the executor invoked the skill: +**0 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 +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, and that same restriction excludes +the real path. Fixed to accept a `Skill` tool_use naming wrap-up, with negative controls +for a Bash echo and for a different skill. **A heartbeat file (`tarbuckle-wrap-lastrun`, +overwritten, never appended) now proves the hook runs at all** — the silent-net objection +this surface carried from the start. + ⚠ **Bounded generation is legitimate because of the steward's ruling**, not despite it: *"A guaranteed occasion is not a guaranteed utterance. If a seam produces nothing that passes, let it produce nothing."* Timeout ⇒ silence, logged. diff --git a/scripts/tarbuckle-wrap.py b/scripts/tarbuckle-wrap.py index 1724fc7..c722919 100755 --- a/scripts/tarbuckle-wrap.py +++ b/scripts/tarbuckle-wrap.py @@ -35,6 +35,12 @@ from tarbuckle_mumble_shim import (acceptable, soul_register, session_material, 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 @@ -74,13 +80,36 @@ def wrap_invoked(transcript_path: str) -> bool: 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 + # (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 @@ -146,6 +175,12 @@ def main() -> int: 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): @@ -229,6 +264,20 @@ def selftest() -> int: 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