[FIX] wrap: a generation's Stop is not a turn (PENDING-169 §5, judgment 4)

`Stop` fires for `claude -p`, so the wrap hook re-entered inside the fool's own
generator child. Measured over the fortnight to 2026-09-09: 3 of 10 wrap runs
landed in a generation subprocess, and 18 s of the 80 s of blocking `Stop` —
30% of occasions, 23% of the time — went on occasions that were the end of a
generation nobody was waiting on.

The gate is one predicate against TARBUCKLE_CHILD, which the four seams already
set on the spawn and which `tarbuckle-body.py:203` already reads as a fork-bomb
guard. ⚠ The variable now carries two meanings that do not imply each other —
DO NOT TICK in the body, DO NOT SPEAK OR WORK here — and both sites now say so,
because the next reader would otherwise remove the coupling as arbitrary.

The heartbeat write stays ABOVE the gate, deliberately: it answers "did the hook
fire", and gating it would narrow the one artifact that separates a hook that
never fires from a hook that fires and does nothing.

W6/W6n are BEHAVIOURAL, not source strings — the source-string form of this kind
of check is what passed vacuously for its whole life in the seam (PENDING-180).
Verified by mutation, not by the green run: deleting the gate fails W6 and W6n;
hoisting it above the heartbeat fails the heartbeat arm; nothing else moves.
28/28 controls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T1i5VRfHjD79hfaXjWsBXA
This commit is contained in:
David F Glidden
2026-09-10 18:53:06 +02:00
co-authored by Claude Opus 5
parent 1e019b407d
commit 7948c0929b
+58
View File
@@ -181,6 +181,23 @@ def main() -> int:
fh.write(time.strftime("%Y-%m-%dT%H:%M:%S%z")) fh.write(time.strftime("%Y-%m-%dT%H:%M:%S%z"))
except OSError: except OSError:
pass pass
# ⚠ `Stop` FIRES FOR `claude -p` TOO, so this hook re-enters inside the fool's own
# generator child. Measured over the fortnight to 2026-09-09: 3 of 10 wrap runs landed
# in a generation subprocess, and 18 s of the 80 s of blocking `Stop` went on occasions
# that were the end of a generation nobody was waiting on. The child already carries the
# variable — it is set on the spawn below, and by the three sibling seams.
# ⚠ THE HEARTBEAT STAYS ABOVE THIS GATE, deliberately: it answers "did the hook fire",
# and gating it would quietly narrow the one artifact that can tell a hook that never
# fires from a hook that fires and does nothing.
# ⚠ THE VARIABLE NOW MEANS TWO THINGS AND NEITHER IMPLIES THE OTHER. In
# `tarbuckle-body.py` it means DO NOT TICK — a fork-bomb guard on the status line.
# Here it means DO NOT SPEAK OR WORK — a generation's `Stop` is not a turn, and he has
# no business wrapping a session that is one of his own sentences. Whoever deletes one
# meaning must check the other: they are separate guards that share a name.
if os.environ.get("TARBUCKLE_CHILD"):
return 0
session_id = str(payload.get("session_id") or "") session_id = str(payload.get("session_id") or "")
transcript = payload.get("transcript_path") or "" transcript = payload.get("transcript_path") or ""
if not transcript or not wrap_invoked(transcript): if not transcript or not wrap_invoked(transcript):
@@ -219,6 +236,9 @@ def main() -> int:
def selftest() -> int: def selftest() -> int:
# W6 rebinds these three for the duration of one control and restores them; Python
# requires the declaration ahead of first use, which W3's calls would otherwise be.
global wrap_invoked, muted, HEARTBEAT
checks, failed = [], [] checks, failed = [], []
import tempfile import tempfile
@@ -302,6 +322,44 @@ def selftest() -> int:
ck("W5 prompt keeps no truth value", "NO TRUTH VALUE" 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) ck("W5n wrap prompt differs from the wake prompt", "put down" in p)
# W6 — `Stop` fires for `claude -p`, so this hook re-enters inside the fool's own
# generator. ⚠ BEHAVIOURAL, NOT A SOURCE STRING. The source-string form of exactly
# this kind of check is what passed vacuously for its whole life two files away
# (PENDING-180), and no string in this file can see whether the gate is reached
# BEFORE the work — which is the entire claim.
import io as _io
_wi, _mu, _hb = wrap_invoked, muted, HEARTBEAT
_env, _stdin, seen = os.environ.get("TARBUCKLE_CHILD"), sys.stdin, []
_hbt = os.path.join(td, "hb")
def _spy(path):
seen.append(path)
return False
_payload = json.dumps({"session_id": "w6", "transcript_path": t})
try:
# muted() is neutralised for the duration: a control whose outcome depends on a
# switch it does not name is not a control, and mute is a different question.
wrap_invoked, muted, HEARTBEAT = _spy, (lambda: False), _hbt
os.environ["TARBUCKLE_CHILD"] = "1"
sys.stdin = _io.StringIO(_payload)
ck("W6 a generation's Stop returns without reading the transcript",
main() == 0 and not seen)
ck("W6 the heartbeat is written above the gate, so liveness still reports",
os.path.exists(_hbt))
os.environ.pop("TARBUCKLE_CHILD", None)
sys.stdin = _io.StringIO(_payload)
main()
ck("W6n the predicate can fail: an ordinary Stop does read the transcript",
len(seen) == 1)
finally:
wrap_invoked, muted, HEARTBEAT = _wi, _mu, _hb
sys.stdin = _stdin
if _env is None:
os.environ.pop("TARBUCKLE_CHILD", None)
else:
os.environ["TARBUCKLE_CHILD"] = _env
for name in checks: for name in checks:
print(f" {'FAIL' if name in failed else 'ok '} {name}") print(f" {'FAIL' if name in failed else 'ok '} {name}")
print(f"{len(checks) - len(failed)}/{len(checks)} controls passed") print(f"{len(checks) - len(failed)}/{len(checks)} controls passed")