import io

p = r"C:\Users\decid\Documents\projects\spt-claude-code\tools\claude-spt\src\hook.rs"
s = io.open(p, encoding="utf-8").read()


def sub(old, new, count=1):
    global s
    assert s.count(old) == count, (s.count(old), old[:90])
    s = s.replace(old, new)


# ---------------------------------------------------------------- the frame leg
sub(
    """/// The shared leg: examine the drop and arm the wake/clear boundary if it is an across-commune.""",
    '''/// THE FRAME BACKSTOP. Ask spt-core what it ingested, instead of asking the disk what is still
/// there. Runs at `Stop` only — once per turn, not once per tool call.
///
/// WHY THIS EXISTS (v0.38.0, and it is the correction to v0.37.0's central assumption): the drop
/// file is deleted by core's watcher when it ingests, MEASURED on this node at 2.8s / 3.8s / 5.8s —
/// not the >15s the file-stat design was built on. A hook cycle is routinely longer than that,
/// because the gap between hooks includes the agent's own thinking time. So a commune written as a
/// turn's FINAL act is typically gone before `Stop` runs, and the file legs see nothing at all.
/// What survives is the `COMMUNE` io frame, which carries the drop's bytes VERBATIM — markers and
/// all, since core carries them through untouched and acts on none of them.
///
/// The two legs are complementary rather than redundant, and that is the whole design: the file leg
/// wins while the drop is still on disk (before ingest), the frame leg wins once it is not (after
/// ingest). Between them they cover the window with no gap for the boundary to fall through.
///
/// AUTHENTICATION IS NOT A PROBLEM HERE, though it is what made this look impossible: `api
/// io-events` takes `--session-id <sid>` as both cursor key and proof, and every hook already
/// carries a sid. It is a process WITHOUT one — a between-turns ResidentService — that cannot read
/// the funnel (filed upstream). A hook can.
/// [impl->REQ-COMMUNE-FRAME-BACKSTOP]
fn arm_from_commune_frame(env: &mut dyn HookEnv, id: &str, sid: &str) {
    if id.is_empty() || sid.is_empty() {
        return;
    }
    // An arm is already pending — the file leg caught this same drop moments ago, or a previous
    // turn armed and the `/clear` has not landed yet. Never arm twice: two post-clear sequences
    // BLEND rather than fail loudly. [impl->REQ-HAZARD-COMMUNE-DROP-REARM]
    if env.read_adapter_state(&clearing_latch_rel(id)).is_some() {
        return;
    }
    let out = match env.spt_strict(&[
        "api", "--adapter", ADAPTER, "io-events", id, "--session-id", sid, "--json",
    ]) {
        Ok(o) => o,
        Err(e) => {
            // LOUD: a refused poll is a boundary that will not fire, and the failure mode it
            // replaces (silently missing the handoff turn) is exactly what this leg exists for.
            env.log(&format!(
                "claude-spt hook: commune-frame poll REFUSED for {id} — {e}; an across-commune ingested this turn will not fire its boundary from the frame leg (the drop leg still covers a drop that is still on disk)"
            ));
            return;
        }
    };
    let Ok(v) = serde_json::from_str::<Value>(&out) else {
        env.log(&format!(
            "claude-spt hook: commune-frame poll for {id} returned unparseable JSON — treating as no frames"
        ));
        return;
    };
    let Some(events) = v.get("events").and_then(|e| e.as_array()) else { return };
    for e in events {
        if e.get("kind").and_then(|k| k.as_str()) != Some("COMMUNE") {
            continue;
        }
        let payload = e.get("payload").and_then(|p| p.as_str()).unwrap_or_default();
        if e.get("truncated").and_then(|t| t.as_bool()).unwrap_or(false) {
            // REFUSE, LOUDLY, RATHER THAN GUESS. The payload is capped at the 16KB class and the
            // wake marker conventionally sits at the END of a commune — exactly what a cap cuts.
            // Arming anyway would clear a session on a commune that never asked for it; skipping
            // silently would drop a handoff. So: never clear on a guess, and say so. These frames
            // carry no `digest_seq` to follow (28 observed, none truncated, none carrying one), so
            // there is no recovery path adapter-side; that gap is filed upstream.
            env.log(&format!(
                "claude-spt hook: commune frame for {id} is TRUNCATED ({} bytes visible) and carries no digest_seq to follow — cannot tell whether it is an across-commune, so NO boundary is armed. If this was a commune-across, re-issue it or clear manually",
                payload.len()
            ));
            continue;
        }
        if !has_wake_marker(payload) {
            continue;
        }
        env.log(&format!(
            "claude-spt hook: across-commune detected in an ingested COMMUNE frame for {id} — the drop was already consumed by core, so the frame is the only surviving evidence; arming the boundary from it"
        ));
        arm_wake(env, id, sid, payload);
        return;
    }
}

/// The shared leg: examine the drop and arm the wake/clear boundary if it is an across-commune.''',
)

# ---------------------------------------------------------------- wire it at Stop
sub(
    '''    arm_from_commune_drop(env, &id, &sid, &hook_cwd(v), "Stop");''',
    '''    arm_from_commune_drop(env, &id, &sid, &hook_cwd(v), "Stop");
    // …and then ask core what it ingested, for the drop this turn wrote and core already consumed.
    // Ordered after the disk leg so a drop still ON disk arms from the file (no poll needed) and
    // only a drop already GONE costs the extra call. Both are ahead of the quiet-window check, so
    // whichever one arms, this same Stop holds the window and records `stop_seen`.
    // [impl->REQ-COMMUNE-FRAME-BACKSTOP]
    arm_from_commune_frame(env, &id, &sid);''',
)

io.open(p, "w", encoding="utf-8", newline="\n").write(s)
print("ok")
