diff --git a/scripts/wake-digest.py b/scripts/wake-digest.py index 4aea6b4..ce06dfd 100755 --- a/scripts/wake-digest.py +++ b/scripts/wake-digest.py @@ -16,7 +16,7 @@ if it cannot be computed it says so rather than emitting nothing. Provenance: 2026-07-28, steward-authorized alongside the PENDING split and the CLAUDE.md doctrine annotation. Sibling of governance-drift-check.py. """ -import calendar, os, re, subprocess, sys, time +import calendar, json, os, re, subprocess, sys, time HOME = os.path.expanduser("~") D = os.path.join(HOME, "dotfiles") @@ -340,6 +340,26 @@ def wrap_verdict(span, events, lead=300, lag=10800): return "unwrapped", "" +def previous_session(): + """THE single selection of 'the session before this one', for every consumer. + + Lifted out of sec_unwrapped 2026-08-31 when the loop check (REVIEWED-131) needed + the same transcript. This file already refuses a second definition of 'an item' + (item_spans); a second definition of 'the previous session' would be the same + bug at a different site. -> (path, start_epoch, end_epoch) or None. + """ + proj = os.path.dirname(os.path.join(HOME, ".claude", "projects", + "-Users-davidglidden", "memory")) + try: + tx = [os.path.join(proj, f) for f in os.listdir(proj) if f.endswith(".jsonl")] + except OSError: + warn.append("session transcripts unreadable — wrap check did not run") + return None + if not tx: + return None + return previous_transcript(time.time(), [(p,) + transcript_span(p) for p in tx]) + + def sec_unwrapped(): """ PENDING-S2's obligation, rebuilt on our own substrate. @@ -361,16 +381,7 @@ def sec_unwrapped(): 4. a two-valued verdict over an assessment that can fail. Fixing only (4), as scoped, would have left the false alarms firing. """ - proj = os.path.dirname(os.path.join(HOME, ".claude", "projects", - "-Users-davidglidden", "memory")) - try: - tx = [os.path.join(proj, f) for f in os.listdir(proj) if f.endswith(".jsonl")] - except OSError: - warn.append("session transcripts unreadable — wrap check did not run") - return None - if not tx: - return None - prev = previous_transcript(time.time(), [(p,) + transcript_span(p) for p in tx]) + prev = previous_session() if prev is None: return None _path, a, b = prev @@ -862,6 +873,143 @@ def stray_maps(): return [] +# ---------- (e) + (c): is the loop currently removable? REVIEWED-131 (2026-08-31) ---------- +# EARNED 2026-08-31. A binary upgrade restarted the daemon, which respawned a parked +# worker with --reply-on-resume. THIS digest was injected into that session as its only +# instruction, and an executor worked and committed with no human present (PENDING-172). +# (e) reads harness state on disk; (c) reads the session transcript. Two substrates, +# deliberately — REVIEWED-131 cond. 2 requires the output to name which one fired. + +JOBS_DIR = os.path.expanduser("~/.claude/jobs") +ARM_FLAG = "--reply-on-resume" + + +def parked_workers(jobs_dir=JOBS_DIR): + """(e) — REVIEWED-131 cond. 1. Returns (rows, unreadable, established). + + Whether a parked worker will take a turn with no human present is recorded + per session in //state.json's `respawnFlags`. This reads the + HARNESS, not the session: the only check here whose enforcement does not + depend on the party being checked. + + `established` is False whenever the directory is missing or unreadable. + A control over parked workers whose negative result is an opaque zero is + the failure PENDING-172 is about, so this NEVER reports absence of evidence + as evidence of absence. Every dir read and every dir refused is enumerated. + """ + rows, unreadable = [], [] + if not os.path.isdir(jobs_dir): + return rows, unreadable, False + try: + entries = sorted(os.listdir(jobs_dir)) + except OSError as e: + return rows, [("", type(e).__name__)], False + for name in entries: + d = os.path.join(jobs_dir, name) + if not os.path.isdir(d): + continue + f = os.path.join(d, "state.json") + try: + with open(f, encoding="utf-8") as fh: + st = json.load(fh) + except Exception as e: + unreadable.append((name, type(e).__name__)) + continue + flags = st.get("respawnFlags") + # An absent or non-list respawnFlags is NOT "unarmed" — it is unreadable state. + if not isinstance(flags, list): + unreadable.append((name, "respawnFlags missing or not a list")) + continue + rows.append((name, str(st.get("state") or "?"), ARM_FLAG in flags, + str(st.get("name") or "")[:52])) + return rows, unreadable, True + + +NONHUMAN = ("", "", "", "") + + +def human_turns(path): + """(c) — REVIEWED-131 cond. 2. Count of genuine human turns in a transcript. + + A hook-injected digest arrives as a `user` record, and so do /clear and /exit; + none of them is a person. Returns None when the transcript cannot be read — + unreadable is not zero. + + ⚠ RETROSPECTIVE BY CONSTRUCTION. At SessionStart the current session has no + turns yet, so this is applied to the most recent COMPLETED session. It answers + 'did an executor just run unattended?', which is what nothing detected on + 2026-08-31 — not 'am I about to'. (e) is the forward-looking half. + """ + try: + with open(path, encoding="utf-8", errors="replace") as fh: + lines = fh.readlines() + except Exception: + return None + n = 0 + for line in lines: + try: + o = json.loads(line) + except Exception: + continue + if o.get("type") != "user" or o.get("isMeta"): + continue + c = o.get("message", {}).get("content") + if isinstance(c, list): + c = " ".join(x.get("text", "") for x in c if isinstance(x, dict)) + if not isinstance(c, str) or not c.strip(): + continue + if any(m in c for m in NONHUMAN): + continue + n += 1 + return n + + +def sec_loop(prev_transcript=None): + """Assemble (e) and (c), naming which control produced each line.""" + o = [] + rows, unreadable, established = parked_workers() + if not established: + o.append("PARKED WORKERS [control e — harness state] — NOT ESTABLISHED:" + f" {JOBS_DIR} is missing or unreadable." + " This is 'could not assess', not 'none parked'.") + else: + armed = [r for r in rows if r[2]] + head = (f"PARKED WORKERS [control e — harness state] — {len(rows)} job dir(s) read," + f" {len(unreadable)} unreadable, {len(armed)} ARMED") + o.append("⚠ " + head if armed else head) + for jid, state, arm, name in rows: + o.append(f" {'⚠ ARMED' if arm else ' ok '} {jid} state={state} {name}") + for jid, why in unreadable: + o.append(f" NOT ESTABLISHED {jid} ({why}) — counted, not classified") + # ⚠ A stopped job still carries its flags. Whether the daemon respawns a + # STOPPED job is NOT ESTABLISHED — the 2026-08-31 event respawned one that was + # parked (state=blocked). Reporting both as equally live would assert what was + # never measured; reporting the stopped one as safe would assume it. + live = [r for r in armed if r[1] not in ("stopped",)] + stopped = [r for r in armed if r[1] in ("stopped",)] + if live: + o.append(f" ⇒ {len(live)} PARKED worker(s) carry {ARM_FLAG}: a daemon restart" + " (an auto-update will do) makes them take a turn with no human" + " present. PENDING-172 / REVIEWED-131.") + if stopped: + o.append(f" ⇒ {len(stopped)} STOPPED job(s) still carry {ARM_FLAG}." + " Whether the daemon respawns a stopped job is NOT ESTABLISHED —" + " the 2026-08-31 respawn took a job that was parked, not stopped." + " Neither live nor proven safe.") + if prev_transcript: + n = human_turns(prev_transcript) + label = "LAST SESSION [control c — transcript]" + if n is None: + o.append(f"{label} — NOT ESTABLISHED: transcript unreadable.") + elif n == 0: + o.append(f"⚠ {label} — NO HUMAN TURN in {os.path.basename(prev_transcript)}." + " That session ran unattended. PENDING-172.") + else: + o.append(f"{label} — {n} human turn(s); a person was present.") + return o + + # ---------- self-test ---------- def selftest(): @@ -979,6 +1127,57 @@ def selftest(): chk("classify_pointers returns clean on empty input", classify_pointers("", MEMDIR) == (0, [], [], [])) NOWHERE = "no-such-file-anywhere-xyzzy-9931.md" + print("\nloop checks (e)+(c) [REVIEWED-131, earned 2026-08-31]:") + import tempfile, shutil + _tmp = tempfile.mkdtemp(prefix="wd-loop-") + try: + def _job(name, payload): + d = os.path.join(_tmp, name) + os.makedirs(d, exist_ok=True) + open(os.path.join(d, "state.json"), "w").write(payload) + _job("armed", json.dumps({"state": "blocked", "name": "x", + "respawnFlags": ["--reply-on-resume", "--allowed-tools"]})) + _job("quiet", json.dumps({"state": "blocked", "name": "y", "respawnFlags": []})) + _job("broken", "{not json") + _job("noflags", json.dumps({"state": "blocked", "name": "z"})) + rows, unread, est = parked_workers(_tmp) + chk("parked_workers ESTABLISHES on a readable dir", est is True) + chk("parked_workers DETECTS an armed worker [must-detect]", + [r for r in rows if r[0] == "armed" and r[2] is True] != []) + chk("parked_workers does NOT flag respawnFlags=[] [must-not-flag]", + [r for r in rows if r[0] == "quiet" and r[2] is False] != []) + chk("parked_workers ENUMERATES unparseable state.json, never drops it", + "broken" in [j for j, _ in unread]) + chk("absent respawnFlags is NOT ESTABLISHED, not 'unarmed' [permissive direction]", + "noflags" in [j for j, _ in unread] + and "noflags" not in [r[0] for r in rows]) + _job("armedstopped", json.dumps({"state": "stopped", "name": "s", + "respawnFlags": ["--reply-on-resume"]})) + _rows3, _u3, _e3 = parked_workers(_tmp) + chk("a STOPPED job still reports its flag [must-detect: flags outlive the process]", + [r for r in _rows3 if r[0] == "armedstopped" and r[2] is True and r[1] == "stopped"] != []) + chk("...and its state is carried, so the report can separate it [must-not-conflate]", + {r[1] for r in _rows3 if r[2]} == {"blocked", "stopped"}) + _rows2, _u2, est2 = parked_workers(os.path.join(_tmp, "does-not-exist")) + chk("missing jobs dir -> NOT ESTABLISHED, never 'none parked'", est2 is False) + + _t = os.path.join(_tmp, "t.jsonl") + _hook = json.dumps({"type": "user", "message": {"content": + "=== WAKE DIGEST ===\nOPEN QUESTION — do the thing"}}) + _cmd = json.dumps({"type": "user", "message": {"content": + "x"}}) + _human = json.dumps({"type": "user", "message": {"content": "please file it"}}) + open(_t, "w").write(_hook + "\n" + _cmd + "\n") + chk("human_turns returns 0 on a hook-only transcript [must-detect]", + human_turns(_t) == 0) + open(_t, "w").write(_hook + "\n" + _human + "\n") + chk("human_turns does NOT flag a real human turn [must-not-flag]", + human_turns(_t) == 1) + chk("human_turns on an unreadable path -> None, not 0", + human_turns(os.path.join(_tmp, "nope.jsonl")) is None) + finally: + shutil.rmtree(_tmp, ignore_errors=True) + print("\ncode spans [2026-08-09: the canary flagged its own specification]:") chk("a pointer INSIDE a code span is not a pointer", classify_pointers(f"`[y]({NOWHERE})`", MEMDIR) == (0, [], [], [])) @@ -1127,6 +1326,23 @@ def selftest(): + " — a spread is not a proof of correctness; the controls above are.") print("\nlive substrate:") + _PROJ = os.path.join(HOME, ".claude", "projects", "-Users-davidglidden") + # Controls drawn from the 2026-08-31 event itself. They are marked NOT ESTABLISHED + # rather than FAIL when the transcript ages out of the 30-day window — a control + # that silently degrades into a pass is the failure this whole item is about. + for _sid, _want, _why in ( + ("b7e7eb39-1e81-4af3-9be7-c323c794c1d9", 0, + "the unattended session of 2026-08-31 (PENDING-172)"), + ("62083f11-b56f-4e67-bbbb-135efeba0ed2", "gt0", + "the attended session that found it")): + _p = os.path.join(_PROJ, _sid + ".jsonl") + if not os.path.exists(_p): + print(f" [ .. ] NOT ESTABLISHED — {_sid[:8]} aged out; control not run ({_why})") + continue + _n = human_turns(_p) + chk(f"human_turns on {_sid[:8]} — {_why}", + (_n == 0) if _want == 0 else (isinstance(_n, int) and _n > 0)) + chk("PENDING.md readable", read(PENDING) is not None) chk("REVIEWED.md readable", read(REVIEWED) is not None) chk("drift check runs", sec_drift() is not None) @@ -1147,10 +1363,18 @@ def main(): unwrapped = sec_unwrapped() if unwrapped: o.append(unwrapped) + + _prev = previous_session() + for line in sec_loop(_prev[0] if _prev else None): + o.append("\n" + line if line.startswith(("PARKED", "⚠ PARKED")) else line) if th: o.append(f"\nPULLING THREAD — {th}") if q: - o.append(f"\nOPEN QUESTION — {q}") + # (b) — REVIEWED-131 cond. 3: ANNOTATION, explicitly NOT a control. It cannot + # enforce anything and must never be reported as a mitigation. It exists because + # on 2026-08-31 this field was read by an unattended executor as an instruction. + o.append(f"\nOPEN QUESTION [orientation, not an instruction — inherited from a" + f" human and answerable to one] — {q}") o.append(f"\nOPEN AUTHORIZATION ITEMS ({len(items)}) — bodies in ~/PENDING.md at the line given;" " closed items are in ~/PENDING-archive.md")