            assert!(ticks.load(Ordering::Relaxed) >= 3, "loop ran repeated ticks");
        });
    }
}

            assert!(ticks.load(Ordering::Relaxed) >= 3, "loop ran repeated ticks");
        });
    }

    // [unit->REQ-DAEMON-4] the supervised-crash path runs the SAME ordering as a
    // graceful teardown: the echo-commune (3.3) fires and the perch is torn down
    // when the post-grace recheck confirms the harness owner is still gone.
    #[test]
    fn crash_signoff_tears_down_when_still_gone() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest_with_echo(drops.path());
            // A short crash_grace keeps the test quick (the grace wait is real).
            let cfg = DaemonConfig { crash_grace: Duration::from_millis(10), ..DaemonConfig::default() };
            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");

            let ready = perch::resolve_ready_file("doyle", ParentHint::Infer);
            std::fs::create_dir_all(ready.parent().unwrap()).unwrap();
            std::fs::write(&ready, "").unwrap();

            // still_gone = true: the Self did not recover across the grace window.
            let outcome = host.supervised_crash_signoff(Some("sid-1"), || true).expect("signoff");
            assert!(outcome.echo_ran, "echo-before-signoff (3.3) must have run");
            assert!(outcome.signed_off, "still-gone Self is torn down");
            assert!(
                drops.path().join("doyle-commune.md").exists(),
                "the echo wrote a final-delta commune before teardown"
            );
            assert!(!ready.exists(), "teardown removed the ready marker");
        });
    }

    // [unit->REQ-HAZARD-GRACE-BEFORE-SIGNOFF] a Self that recovers across the
    // grace window is NOT signed off — the phantom-INIT_SIGNOFF bug (1.1). The
    // echo still fires (it precedes the recheck), but no teardown happens.
    #[test]
    fn crash_signoff_spares_recovered_self() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest_with_echo(drops.path());
            let cfg = DaemonConfig { crash_grace: Duration::from_millis(10), ..DaemonConfig::default() };
            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");

            let ready = perch::resolve_ready_file("doyle", ParentHint::Infer);
            std::fs::create_dir_all(ready.parent().unwrap()).unwrap();
            std::fs::write(&ready, "").unwrap();

            // still_gone = false: the Self came back alive during the grace wait.
            let outcome = host.supervised_crash_signoff(Some("sid-1"), || false).expect("signoff");
            assert!(outcome.echo_ran, "echo precedes the recheck, so it still ran");
            assert!(!outcome.signed_off, "a recovered Self must never be signed off");
            assert!(ready.exists(), "the live Self's ready marker is left intact");
        });
    }

    // [unit->REQ-DAEMON-4] liveness for the supervised Self anchors on the stable
    // harness pid (`parent_pid`), never an ephemeral poll pid (2.1): a dead owner
    // reads gone, a live owner reads present, and no recorded owner reads gone.
    #[test]
    fn harness_owner_gone_anchors_on_parent_pid() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest(drops.path());
            let host = BrainLifecycle::with_config(&manifest, "doyle", DaemonConfig::default())
                .expect("live host");

            // No perch yet → no recorded owner → treated as gone.
            assert!(host.harness_owner_gone(), "an unsupervised perch reads gone");

            // A live owner (this very process) reads present.
            seed_supervised_perch("doyle", std::process::id());
            assert!(!host.harness_owner_gone(), "a live parent_pid reads present");

            // A dead owner reads gone.
            seed_supervised_perch("doyle", DEAD_PID);
            assert!(host.harness_owner_gone(), "a dead parent_pid reads gone");
        });
    }

    // [unit->REQ-DAEMON-4] the orphan-watch loop fires the crash signoff on an
    // observed owner death, sweeping a stale sentinel first (3.2) and tearing the
    // perch down once the recheck confirms the dead owner.
    #[test]
    fn orphan_watch_fires_on_dead_owner() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest_with_echo(drops.path());
            let cfg = DaemonConfig {
                pulse_period: Duration::from_millis(10),
                crash_grace: Duration::from_millis(10),
                ..DaemonConfig::default()
            };
            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");

            // Supervise a perch whose harness owner is already dead.
            seed_supervised_perch("doyle", DEAD_PID);
            let ready = perch::resolve_ready_file("doyle", ParentHint::Infer);
            assert!(ready.exists(), "precondition: ready marker present");

            let stop = AtomicBool::new(false);
            let outcome = host
                .run_orphan_watch(Some("sid-1"), DEAD_PID, &stop)
                .expect("watch fired")
                .expect("signoff ok");
            assert!(outcome.echo_ran && outcome.signed_off, "dead owner → echo + teardown");
            assert!(!ready.exists(), "the orphan-watch tore the perch down");
        });
    }

    // [unit->REQ-DAEMON-4] the orphan-watch honors the stop flag and never fires
    // while the supervised owner is alive (no false-positive teardown).
    #[test]
    fn orphan_watch_honors_stop_for_live_owner() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest_with_echo(drops.path());
            let cfg = DaemonConfig { pulse_period: Duration::from_millis(10), ..DaemonConfig::default() };
            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");

            seed_supervised_perch("doyle", std::process::id());
            let ready = perch::resolve_ready_file("doyle", ParentHint::Infer);

            // Owner is this (alive) process; stop is already set → loop exits with
            // no signoff and leaves the perch untouched.
            let stop = AtomicBool::new(true);
            let outcome = host.run_orphan_watch(Some("sid-1"), std::process::id(), &stop);
            assert!(outcome.is_none(), "a live owner under stop never signs off");
            assert!(ready.exists(), "a live Self's perch is left intact");
        });
    }
}

+    // [unit->REQ-DAEMON-4] the supervised-crash path runs the SAME ordering as a

+    // graceful teardown: the echo-commune (3.3) fires and the perch is torn down

+    // when the post-grace recheck confirms the harness owner is still gone.

+    fn crash_signoff_tears_down_when_still_gone() {

+            let drops = tempfile::tempdir().unwrap();

+            let manifest = live_manifest_with_echo(drops.path());

+            // A short crash_grace keeps the test quick (the grace wait is real).

+            let cfg = DaemonConfig { crash_grace: Duration::from_millis(10), ..DaemonConfig::default() };

+            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");

+            let ready = perch::resolve_ready_file("doyle", ParentHint::Infer);

+            std::fs::create_dir_all(ready.parent().unwrap()).unwrap();

+            std::fs::write(&ready, "").unwrap();

+            // still_gone = true: the Self did not recover across the grace window.

+            let outcome = host.supervised_crash_signoff(Some("sid-1"), || true).expect("signoff");

+            assert!(outcome.echo_ran, "echo-before-signoff (3.3) must have run");

+            assert!(outcome.signed_off, "still-gone Self is torn down");

+                drops.path().join("doyle-commune.md").exists(),

+                "the echo wrote a final-delta commune before teardown"

+            assert!(!ready.exists(), "teardown removed the ready marker");

+    // [unit->REQ-HAZARD-GRACE-BEFORE-SIGNOFF] a Self that recovers across the

+    // grace window is NOT signed off — the phantom-INIT_SIGNOFF bug (1.1). The

+    // echo still fires (it precedes the recheck), but no teardown happens.

+    fn crash_signoff_spares_recovered_self() {

+            let drops = tempfile::tempdir().unwrap();

+            std::fs::write(&ready, "").unwrap();

+            // still_gone = false: the Self came back alive during the grace wait.

+            let outcome = host.supervised_crash_signoff(Some("sid-1"), || false).expect("signoff");

+            assert!(outcome.echo_ran, "echo precedes the recheck, so it still ran");

+            assert!(!outcome.signed_off, "a recovered Self must never be signed off");

+            assert!(ready.exists(), "the live Self's ready marker is left intact");

+    // [unit->REQ-DAEMON-4] liveness for the supervised Self anchors on the stable

+    // harness pid (`parent_pid`), never an ephemeral poll pid (2.1): a dead owner

+    // reads gone, a live owner reads present, and no recorded owner reads gone.

+    fn harness_owner_gone_anchors_on_parent_pid() {

+            let manifest = live_manifest(drops.path());

+            let host = BrainLifecycle::with_config(&manifest, "doyle", DaemonConfig::default())

+            // No perch yet → no recorded owner → treated as gone.

+            assert!(host.harness_owner_gone(), "an unsupervised perch reads gone");

+            // A live owner (this very process) reads present.

+            seed_supervised_perch("doyle", std::process::id());

+            assert!(!host.harness_owner_gone(), "a live parent_pid reads present");

+            // A dead owner reads gone.

+            seed_supervised_perch("doyle", DEAD_PID);

+            assert!(host.harness_owner_gone(), "a dead parent_pid reads gone");

+    // [unit->REQ-DAEMON-4] the orphan-watch loop fires the crash signoff on an

+    // observed owner death, sweeping a stale sentinel first (3.2) and tearing the

+    // perch down once the recheck confirms the dead owner.

+    fn orphan_watch_fires_on_dead_owner() {

+            let drops = tempfile::tempdir().unwrap();

+                pulse_period: Duration::from_millis(10),

+                crash_grace: Duration::from_millis(10),

+                ..DaemonConfig::default()

+            // Supervise a perch whose harness owner is already dead.

+            assert!(ready.exists(), "precondition: ready marker present");

+            let stop = AtomicBool::new(false);

+                .run_orphan_watch(Some("sid-1"), DEAD_PID, &stop)

+            assert!(outcome.echo_ran && outcome.signed_off, "dead owner → echo + teardown");

+            assert!(!ready.exists(), "the orphan-watch tore the perch down");

+    // [unit->REQ-DAEMON-4] the orphan-watch honors the stop flag and never fires

+    // while the supervised owner is alive (no false-positive teardown).

+    fn orphan_watch_honors_stop_for_live_owner() {

+            let cfg = DaemonConfig { pulse_period: Duration::from_millis(10), ..DaemonConfig::default() };

+            // Owner is this (alive) process; stop is already set → loop exits with

+            // no signoff and leaves the perch untouched.

+            let stop = AtomicBool::new(true);

+            let outcome = host.run_orphan_watch(Some("sid-1"), std::process::id(), &stop);

+            assert!(outcome.is_none(), "a live owner under stop never signs off");

+            assert!(ready.exists(), "a live Self's perch is left intact");


NOTE FROM THE USER: proceed with next steps.


Simplify the redundant recheck wrapper.


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\lifecycle.rs has been updated successfully. (file state is current in your context — no need to Read it back)

let ready = perch::resolve_ready_file(&self.id, ParentHint::Infer);

if let Err(e) = std::fs::remove_file(&ready) {


Build the daemon crate.


Bash

cargo build -p spt-daemon 2>&1 | tail -30

Build spt-daemon


   Compiling spt-daemon v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.22s


Builds. Run the daemon tests.


cargo test -p spt-daemon 2>&1 | tail -40

Run spt-daemon tests


test lifecycle::tests::harness_owner_gone_anchors_on_parent_pid ... ok
test lifecycle::tests::orphan_watch_fires_on_dead_owner ... ok
test lifecycle::tests::orphan_watch_honors_stop_for_live_owner ... ok
test lifecycle::tests::pulse_loop_is_config_paced_and_killable ... ok
test lifecycle::tests::pulse_tick_ingests_drop ... ok
test relay::tests::respawn_loses_no_spooled_message ... ok
test relay::tests::run_honors_stop ... ok
test seedmap::tests::put_refreshes_existing ... ok
test seedmap::tests::put_then_take_consumes_once ... ok
test seedmap::tests::take_absent_is_none ... ok

test result: ok. 36 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.39s

     Running tests\broker.rs (target\debug\deps\broker-bdddb47dc258412d.exe)

running 1 test
test broker_hosts_pty_child_streams_output_and_accepts_input ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s

     Running tests\handoff.rs (target\debug\deps\handoff-d3c789195f45f88d.exe)

running 1 test
test brain_restart_survives_gaplessly_with_fresh_gen_start ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.20s

     Running tests\idempotent.rs (target\debug\deps\idempotent-51f4916298fb459a.exe)

running 1 test
test pty_writes_are_exactly_once_across_brain_crashes ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s

   Doc-tests spt_daemon

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s


config: add crash_grace knob

Add crash_grace Duration to DaemonConfig + crash_grace_ms to file struct + DEFAULT_CRASH_GRACE const. The grace window (hazard 1.1) for the orphan-watch recheck.

lifecycle: supervised-crash signoff + orphan-watch loop

Refactor graceful_signoff to share wiring; add supervised_crash_signoff (real grace + real recheck), harness_owner_gone (parent_pid probe, 2.1), run_orphan_watch (poll owner pid, sweep-on-start 3.2, fire crash signoff on death). Tests: same ordering as graceful; recovered Self spared.

Activate REQ-DAEMON-4 + tag evidence

Set REQ-DAEMON-4 required_stages=["impl","unit"] in traceable-reqs.toml with B7 note; tag impl/unit evidence in lifecycle.rs.

Gate green + commit

cargo test --workspace, clippy -D warnings, traceable-reqs check. Then commit with Co-Authored-By trailer.


All 36 + integration tests pass. Tasks 1+2 done. Now task 3 — activate REQ-DAEMON-4.


ToolSearch

Question: select:TaskUpdate


Question: select:TaskUpdate


Updated task #1 status


Updated task #2 status


Updated task #3 status


52	[[requirements]]
53	id = "REQ-DAEMON-4"
54	title = "Honor every KNOWN-HAZARDS invariant"
55	required_stages = []
56

[[requirements]]
id = "REQ-DAEMON-4"
title = "Honor every KNOWN-HAZARDS invariant"
required_stages = []


Edit

id = "REQ-DAEMON-4"
title = "Honor every KNOWN-HAZARDS invariant"
required_stages = []

id = "REQ-DAEMON-4"
title = "Honor every KNOWN-HAZARDS invariant"
required_stages = ["impl", "unit"]  # activated M3b-B7: orphan-watch carries the graceful ordering (echo 3.3 / grace 1.1 / stale-sweep 3.2) into the supervised-crash path (lifecycle: supervised_crash_signoff / run_orphan_watch / harness_owner_gone); int via the B9 daemon E2E


The file C:\Users\decid\Documents\projects\spt-core\traceable-reqs.toml has been updated successfully. (file state is current in your context — no need to Read it back)

id = "REQ-DAEMON-4"
title = "Honor every KNOWN-HAZARDS invariant"
required_stages = []

id = "REQ-DAEMON-4"
title = "Honor every KNOWN-HAZARDS invariant"
required_stages = ["impl", "unit"]  # activated M3b-B7: orphan-watch carries the graceful ordering (echo 3.3 / grace 1.1 / stale-sweep 3.2) into the supervised-crash path (lifecycle: supervised_crash_signoff / run_orphan_watch / harness_owner_gone); int via the B9 daemon E2E

+required_stages = ["impl", "unit"]  # activated M3b-B7: orphan-watch carries the graceful ordering (echo 3.3 / grace 1.1 / stale-sweep 3.2) into the supervised-crash path (lifecycle: supervised_crash_signoff / run_orphan_watch / harness_owner_gone); int via the B9 daemon E2E

# ───────────────────────────── Storage ──────────────────────────────────────

# git-KV BranchStore substrate (ADR-0011, 2026-06-02; implement M4, optional M3c)


Evidence tagged. Run the full gate.


Bash

Run traceability check


  [OK] REQ-STORE-1  required: []  stages: -doc -impl -unit -int
       spt-store::BranchStore (git branch as versioned KV; commit=checkpoint/tip=resume, atomic multi-key, merge-native sync) is the substrate for coarse/durable/audited state (context, registry snapshot+distribution, daemon checkpoint); hot paths (B5 fsync journal) + indexed queries (SQLite spool) excluded (ADR-0011)
  [OK] REQ-TERM-1  required: [impl, unit]  stages: -doc +impl +unit -int
       Process-supervisor terminal wrapper hosting broker PTYs
  [OK] REQ-TERM-2  required: [impl, unit]  stages: -doc +impl +unit -int
       session-surface abstraction; send-keys + send-line injection
  [OK] REQ-TERM-3  required: [impl, unit]  stages: -doc +impl +unit -int
       Byte-stream remote terminal streaming for v1
  [OK] REQ-TERM-4  required: [impl, unit]  stages: -doc +impl +unit -int
       Live activity buffer (PTY digest): adapter-supplied patterns over broker PTY, spt digest pull + delta-stream, opt-in Path-B log
  [OK] REQ-UPD-1  required: []  stages: -doc -impl -unit -int
       Peer-propagated update over P2P
  [OK] REQ-UPD-2  required: []  stages: -doc -impl -unit -int
       All binaries signature-verified before handoff
  [OK] REQ-UPD-3  required: []  stages: -doc -impl -unit -int
       No endpoint process terminates/suspends during self-update
  [OK] REQ-UPD-4  required: []  stages: -doc -impl -unit -int
       Update gated on user confirmation by default; opt-in full-auto
  [OK] REQ-UPD-5  required: []  stages: -doc -impl -unit -int
       spt-core ripple-updates registered adapters


traceable-reqs check 2>&1 | grep -iE 'REQ-DAEMON-4|findings|FAIL|MISSING|error' | head

Check DAEMON-4 + summary


120 requirements: 120 complete, 0 incomplete, 0 findings
  [OK] REQ-DAEMON-4  required: [impl, unit]  stages: -doc +impl +unit -int
       Concurrent SQLite openers (registry/spool) must not fail with 'database is locked' (4.7)
       Stale registry entries degrade to fallback, never hard-fail (4.3)


REQ-DAEMON-4 green (+impl +unit), 0 findings. Now clippy + workspace test.


cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -15

Clippy with -D warnings


    Checking spt-daemon v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Checking spt v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.87s


cargo test --workspace 2>&1 | grep -iE 'test result|FAILED|error\[' | tail -40

Full workspace test


test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 40 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.28s
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.59s
test effect::tests::failed_effect_is_not_recorded_applied ... ok
test result: ok. 36 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.39s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.20s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s
test result: ok. 35 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.31s
test result: ok. 22 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.09s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.22s
test result: ok. 49 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
test result: ok. 17 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.31s
test result: ok. 41 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.62s
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.01s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s


Gate green across the board. Tasks 3 done, 4 in progress. Check the project's Co-Authored-By trailer, then commit.


Updated task #4 status


git log -1 --format='%(trailers)' && echo "---FILES---" && git status --short

Show last commit trailer + status


Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---FILES---
 M crates/spt-daemon/src/config.rs
 M crates/spt-daemon/src/lifecycle.rs
 M traceable-reqs.toml


git add -A && git commit -q -F - <<'EOF'
feat(daemon): B7 — orphan-watch supervised-crash signoff (REQ-DAEMON-4)

Carry the M2b graceful signoff ordering into the supervised-crash path the
always-on daemon now enables (M2b delivered graceful-only).

The graceful path is an intentional shutdown: zero grace + always-gone recheck.
An unexpected harness death is different — the Self may recover across a brief
window (binary handoff relaunch, stale poll). So supervised_crash_signoff runs
the IDENTICAL load-bearing ordering — echo-commune (3.3) -> grace wait (1.1) ->
liveness recheck -> teardown — but with the real config grace + a real recheck.
A Self that rebound across the grace window is spared (phantom-INIT_SIGNOFF, 1.1).

- config: crash_grace knob (DEFAULT_CRASH_GRACE 2s, crash_grace_ms override).
- lifecycle: factor the shared signoff wiring behind graceful + crash paths so
  the only two things that differ are exactly the two hazard 1.1 turns on (grace
  window, recheck verdict) — the crash path can never drift out of echo-before-
  teardown. harness_owner_gone anchors liveness on the stable parent_pid, never
  an ephemeral poll pid (2.1); run_orphan_watch sweeps a stale sentinel on start
  (3.2), polls the owner, and fires the crash signoff on observed death.

REQ-DAEMON-4 activated ["impl","unit"]; int via the B9 daemon E2E.
Gate: traceable-reqs 120/0 · clippy -D warnings · cargo test --workspace 0 fail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
git log -1 --oneline

Commit B7


warning: in the working copy of 'crates/spt-daemon/src/config.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/lifecycle.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
d06af5d feat(daemon): B7 — orphan-watch supervised-crash signoff (REQ-DAEMON-4)


B7 done + committed (`d06af5d`), tree clean.

**B7 — orphan-watch shipped:**
- `config`: `crash_grace` knob (default 2s, `crash_grace_ms` override) — the 1.1 recovery window.
- `lifecycle`: factored shared signoff wiring → graceful + crash paths differ ONLY in grace + recheck (the two things hazard 1.1 turns on). `supervised_crash_signoff` (real grace + real recheck), `harness_owner_gone` (anchors on `parent_pid`, 2.1), `run_orphan_watch` (sweep-on-start 3.2 → poll owner → fire on death). Recovered Self spared.
- REQ-DAEMON-4 activated `["impl","unit"]`; int deferred to B9 E2E.

**Gate:** traceable-reqs 120/0 · clippy `-D warnings` · `cargo test --workspace` 0 fail (incl. 6 new orphan-watch unit tests).

Checkpoint commune to Psyche before B8.


Write

# Commune — B7 orphan-watch done

**B7 DONE + committed (d06af5d, dev-freeform, tree clean).** REQ-DAEMON-4 activated `["impl","unit"]` (int via B9 E2E). Gate green: traceable-reqs 120/0 · clippy -D warnings · cargo test --workspace 0 fail (+6 new orphan-watch unit tests).

**What B7 built (orphan-watch — the supervised-crash signoff path the always-on daemon enables):**
- `spt-daemon/config.rs`: `crash_grace` knob (`DEFAULT_CRASH_GRACE` 2s, `crash_grace_ms` file override) — the hazard-1.1 recovery window.
- `spt-daemon/lifecycle.rs`: factored the shared signoff wiring (`signoff_with`) so graceful + supervised-crash differ ONLY in grace + recheck — exactly the two knobs hazard 1.1 turns on, so the crash path can never drift out of echo-before-teardown. New: `supervised_crash_signoff` (real config grace + injected `still_gone` recheck), `harness_owner_gone` (re-reads perch, anchors liveness on stable `parent_pid` not ephemeral poll pid — 2.1), `run_orphan_watch` (sweep stale sentinel on start 3.2 → poll owner pid each `pulse_period` → on observed death fire crash signoff with `harness_owner_gone` recheck). A Self that rebound across the grace window (binary handoff) is spared (phantom-INIT_SIGNOFF 1.1). `graceful_signoff` now a thin call into `signoff_with` (zero grace + always-gone).

**Remaining M3b:** B8 (digest daemon-half) · B9 (activation sweep + E2E).

**Next = B8 (digest daemon-half, REQ-TERM-4 `int`, ADR-0008):** manifest `pty_digest` seam (adapter-supplied `input`/`agent`/`tool` patterns) → run the M3a `DigestParser` over the broker PTY; `spt digest <id>` snapshot pull + broker-pushed structured delta-stream on the R-TERM-3 substrate + opt-in Path-B persistence. **Local addressing only** — the qualified `[subnet:]id@node` form is M4, resist pulling multi-subnet forward. Orient: M3b-PLAN B8 row; M3a `DigestParser`; ADR-0008; the broker PTY output stream (brain.rs `read_event`/`OutputEvent`). REQ-TERM-4 currently `["impl","unit"]` — B8 supplies `int`.

**Watch:** broker holds the PTY + serves output over IPC (B1/B2); the digest parser runs over that broker-held stream, not a direct PTY. Don't regress the spawn_detached handle-inheritance guard or the gapless-handoff seam.


File created successfully at: C:\Users\decid\Documents\projects\spt-core\.claude\doyle-commune.md (file state is current in your context — no need to Read it back)

# Commune — B7 orphan-watch done

**B7 DONE + committed (d06af5d, dev-freeform, tree clean).** REQ-DAEMON-4 activated `["impl","unit"]` (int via B9 E2E). Gate green: traceable-reqs 120/0 · clippy -D warnings · cargo test --workspace 0 fail (+6 new orphan-watch unit tests).

**What B7 built (orphan-watch — the supervised-crash signoff path the always-on daemon enables):**
- `spt-daemon/config.rs`: `crash_grace` knob (`DEFAULT_CRASH_GRACE` 2s, `crash_grace_ms` file override) — the hazard-1.1 recovery window.
- `spt-daemon/lifecycle.rs`: factored the shared signoff wiring (`signoff_with`) so graceful + supervised-crash differ ONLY in grace + recheck — exactly the two knobs hazard 1.1 turns on, so the crash path can never drift out of echo-before-teardown. New: `supervised_crash_signoff` (real config grace + injected `still_gone` recheck), `harness_owner_gone` (re-reads perch, anchors liveness on stable `parent_pid` not ephemeral poll pid — 2.1), `run_orphan_watch` (sweep stale sentinel on start 3.2 → poll owner pid each `pulse_period` → on observed death fire crash signoff with `harness_owner_gone` recheck). A Self that rebound across the grace window (binary handoff) is spared (phantom-INIT_SIGNOFF 1.1). `graceful_signoff` now a thin call into `signoff_with` (zero grace + always-gone).

**Remaining M3b:** B8 (digest daemon-half) · B9 (activation sweep + E2E).

**Next = B8 (digest daemon-half, REQ-TERM-4 `int`, ADR-0008):** manifest `pty_digest` seam (adapter-supplied `input`/`agent`/`tool` patterns) → run the M3a `DigestParser` over the broker PTY; `spt digest <id>` snapshot pull + broker-pushed structured delta-stream on the R-TERM-3 substrate + opt-in Path-B persistence. **Local addressing only** — the qualified `[subnet:]id@node` form is M4, resist pulling multi-subnet forward. Orient: M3b-PLAN B8 row; M3a `DigestParser`; ADR-0008; the broker PTY output stream (brain.rs `read_event`/`OutputEvent`). REQ-TERM-4 currently `["impl","unit"]` — B8 supplies `int`.

**Watch:** broker holds the PTY + serves output over IPC (B1/B2); the digest parser runs over that broker-held stream, not a direct PTY. Don't regress the spawn_detached handle-inheritance guard or the gapless-handoff seam.


Commune dropped (Psyche ingests it). B7 closed. Proceeding to **B8 — digest daemon-half** per "next steps."

Orient: M3a `DigestParser`, the `pty_digest` manifest seam, ADR-0008, broker output stream.


Bash

grep -rln 'DigestParser\|pty_digest\|struct Digest\|fn digest' crates/ | head && echo "===TERM-4===" && grep -n -A4 'REQ-TERM-4' traceable-reqs.toml | head

Locate DigestParser + pty_digest seam


crates/spt-term/src/digest.rs
crates/spt-term/src/lib.rs
crates/spt-term/tests/digest.rs
===TERM-4===
346:id = "REQ-TERM-4"
347-title = "Live activity buffer (PTY digest): adapter-supplied patterns over broker PTY, spt digest pull + delta-stream, opt-in Path-B log"
348-required_stages = ["impl", "unit"]
349-[[requirements]]
350-id = "REQ-FRONT-1"


1	//! Live activity buffer (PTY digest) parser primitive (M3a A5, ADR-0008).
2	//!
3	//! A rolling, *parsed*, source-tagged view of recent PTY I/O — the last ~N user
4	//! turns plus the agent output between them, with tool-usage sprints collapsed
5	//! to a single entry. Distinct from raw scrollback (bytes) and transcript
6	//! history (the full conversation); its job is *"what is this agent doing right
7	//! now, at a glance."*
8	//!
9	//! ## The parsing-boundary rule (ADR-0008 — NOT a built-in per-harness parser)
10	//!
11	//! **The adapter supplies the patterns; this primitive only runs them.** Exactly
12	//! Path A's "adapter owns the parser" rule and the "spt-core owns the mechanism,
13	//! adapter owns the pattern" principle (identical to opaque command templates).
14	//! The seam is three adapter-supplied pattern kinds:
15	//! - **`input`** (user-input boundary) and **`agent`** (agent-output boundary) —
16	//!   each *opens a span capturing everything, newlines included, until the next
17	//!   pattern match of any kind*.
18	//! - **`tools`** — a list (one regex per known tool) **plus an optional
19	//!   `catchall`** for unknowable user-installed tools — each extracting a tool
20	//!   `name` and an `arg` fragment via named capture groups.
21	//!
22	//! Window depth `N` and the tool-arg truncation width are spt-core-controlled
23	//! *presentation*; the template controls *extraction*.
24	//!
25	//! ## Scope (M3a is the primitive ONLY)
26	//!
27	//! This is the parser: patterns in, structured [`Digest`] out, fed from the A4
28	//! byte stream. The manifest-sourcing of the patterns, the `spt digest` pull,
29	//! the broker-pushed delta-stream, and Path-B persistence are **M3b** — they need
30	//! the broker + manifest + history store this crate deliberately does not build.
31	//!
32	//! ## OS-neutrality (Spike #4 / #5)
33	//!
34	//! ConPTY repaints on resize — it reorders and *duplicates* stream content. The
35	//! parser therefore re-derives the digest from a bounded rolling text buffer and
36	//! reports the **tail** (last `N` turns): a repaint that resends a turn just
37	//! makes the latest copy win. ANSI control sequences (cursor moves, clears,
38	//! repaint machinery) are stripped before matching so the adapter patterns see
39	//! clean text on both the forkpty raw-pipe and ConPTY screen-buffer backends.
40
41	use regex::Regex;
42
43	/// A failure compiling an adapter-supplied pattern.
44	#[derive(Debug)]
45	pub enum DigestError {
46	    /// A pattern did not compile as a regex. Names which one.
47	    BadPattern { which: String, error: String },
48	}
49
50	impl std::fmt::Display for DigestError {
51	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52	        match self {
53	            DigestError::BadPattern { which, error } => {
54	                write!(f, "digest pattern {which} did not compile: {error}")
55	            }
56	        }
57	    }
58	}
59
60	impl std::error::Error for DigestError {}
61
62	/// The adapter-supplied patterns (regex source strings). `input` / `agent` are
63	/// boundary markers; `tools` + `catchall` extract `name` + `arg` capture groups.
64	#[derive(Debug, Clone)]
65	pub struct DigestPatterns {
66	    /// User-input boundary marker.
67	    pub input: String,
68	    /// Agent-output boundary marker.
69	    pub agent: String,
70	    /// One regex per known tool; each should name an `arg` group (and may name a
71	    /// `name` group — else the tool's matched text leads).
72	    pub tools: Vec<String>,
73	    /// Optional final catchall for user-installed tools.
74	    pub catchall: Option<String>,
75	}
76
77	/// Presentation knobs owned by spt-core (not the template).
78	#[derive(Debug, Clone, Copy)]
79	pub struct DigestConfig {
80	    /// Window depth — keep the last `window_turns` user turns (~3).
81	    pub window_turns: usize,
82	    /// Tool-arg truncation width in characters (~25).
83	    pub arg_truncation: usize,
84	    /// Strip ANSI control sequences before matching (default on — see module docs).
85	    pub strip_ansi: bool,
86	    /// Rolling raw-text buffer cap in bytes; older bytes are dropped from the
87	    /// front so memory stays bounded under an endless stream.
88	    pub max_scan_bytes: usize,
89	}
90
91	impl Default for DigestConfig {
92	    fn default() -> Self {
93	        Self { window_turns: 3, arg_truncation: 25, strip_ansi: true, max_scan_bytes: 64 * 1024 }
94	    }
95	}
96
97	/// A single collapsed tool invocation.
98	#[derive(Debug, Clone, PartialEq, Eq)]
99	pub struct ToolUse {
100	    /// The tool name (e.g. `Write`).
101	    pub name: String,
102	    /// The (truncated) argument fragment.
103	    pub arg: String,
104	}
105
106	/// One source-tagged entry within a turn.
107	#[derive(Debug, Clone, PartialEq, Eq)]
108	pub enum DigestEntry {
109	    /// Agent output text (between boundaries).
110	    Agent(String),
111	    /// A collapsed run of consecutive tool invocations (one "sprint").
112	    ToolSprint(Vec<ToolUse>),
113	}
114
115	/// One user turn: the user input that opened it (None for any pre-input
116	/// preamble) plus the agent output / tool sprints that followed, in order.
117	#[derive(Debug, Clone, PartialEq, Eq, Default)]
118	pub struct Turn {
119	    /// The user input text that opened this turn.
120	    pub input: Option<String>,
121	    /// Agent + tool-sprint entries that followed, in stream order.
122	    pub entries: Vec<DigestEntry>,
123	}
124
125	/// The structured rolling digest — the last `N` turns.
126	#[derive(Debug, Clone, PartialEq, Eq, Default)]
127	pub struct Digest {
128	    /// Turns oldest-to-newest, at most `window_turns` of them.
129	    pub turns: Vec<Turn>,
130	}
131
132	/// One located pattern hit while scanning the buffer.
133	enum Marker {
134	    Input { content: String },
135	    Agent { content: String },
136	    Tool(ToolUse),
137	}
138
139	/// The digest parser: feed bytes from the A4 stream, read a structured [`Digest`].
140	#[derive(Debug)]
141	pub struct DigestParser {
142	    input_re: Regex,
143	    agent_re: Regex,
144	    tool_res: Vec<Regex>,
145	    catchall_re: Option<Regex>,
146	    ansi_re: Option<Regex>,
147	    config: DigestConfig,
148	    /// Rolling, ANSI-stripped text buffer.
149	    buf: String,
150	}
151
152	impl DigestParser {
153	    /// Compile the adapter-supplied patterns. Returns [`DigestError::BadPattern`]
154	    /// naming the first pattern that fails to compile.
155	    //
156	    // [impl->REQ-TERM-4]
157	    pub fn new(patterns: DigestPatterns, config: DigestConfig) -> Result<Self, DigestError> {
158	        let compile = |src: &str, which: &str| {
159	            Regex::new(src).map_err(|e| DigestError::BadPattern {
160	                which: which.to_string(),
161	                error: e.to_string(),
162	            })
163	        };
164	        let input_re = compile(&patterns.input, "input")?;
165	        let agent_re = compile(&patterns.agent, "agent")?;
166	        let mut tool_res = Vec::with_capacity(patterns.tools.len());
167	        for (i, t) in patterns.tools.iter().enumerate() {
168	            tool_res.push(compile(t, &format!("tools[{i}]"))?);
169	        }
170	        let catchall_re = match &patterns.catchall {
171	            Some(c) => Some(compile(c, "catchall")?),
172	            None => None,
173	        };
174	        // CSI / OSC / lone-ESC stripper. Compiled by us, never fails.
175	        let ansi_re = if config.strip_ansi {
176	            Some(
177	                Regex::new(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]")
178	                    .expect("static ANSI regex compiles"),
179	            )
180	        } else {
181	            None
182	        };
183	        Ok(Self {
184	            input_re,
185	            agent_re,
186	            tool_res,
187	            catchall_re,
188	            ansi_re,
189	            config,
190	            buf: String::new(),
191	        })
192	    }
193
194	    /// Feed a chunk of the byte stream. Decoded lossily to UTF-8, ANSI-stripped
195	    /// (if configured), normalized (`\r\n`/`\r` → `\n`), appended, and trimmed to
196	    /// the rolling cap.
197	    //
198	    // [impl->REQ-TERM-4]
199	    pub fn feed(&mut self, bytes: &[u8]) {
200	        let text = String::from_utf8_lossy(bytes);
201	        let cleaned = match &self.ansi_re {
202	            Some(re) => re.replace_all(&text, "").into_owned(),
203	            None => text.into_owned(),
204	        };
205	        // Normalize carriage returns left by ConPTY repaint so boundary patterns
206	        // and span content match on line-oriented text.
207	        let cleaned = cleaned.replace("\r\n", "\n").replace('\r', "");
208	        self.buf.push_str(&cleaned);
209
210	        if self.buf.len() > self.config.max_scan_bytes {
211	            // Drop from the front, on a char boundary.
212	            let cut = self.buf.len() - self.config.max_scan_bytes;
213	            let mut idx = cut;
214	            while idx < self.buf.len() && !self.buf.is_char_boundary(idx) {
215	                idx += 1;
216	            }
217	            self.buf.drain(..idx);
218	        }
219	    }
220
221	    /// Re-derive the structured digest from the current buffer: scan all pattern
222	    /// hits in order, fold boundary spans + collapsed tool sprints into turns,
223	    /// and return the last `window_turns`.
224	    //
225	    // [impl->REQ-TERM-4]
226	    pub fn digest(&self) -> Digest {
227	        let markers = self.scan_markers();
228	        let mut turns: Vec<Turn> = Vec::new();
229	        let mut current = Turn::default();
230	        let mut current_started = false;
231
232	        for m in markers {
233	            match m {
234	                Marker::Input { content } => {
235	                    if current_started || !current.entries.is_empty() || current.input.is_some() {
236	                        turns.push(std::mem::take(&mut current));
237	                    }
238	                    current = Turn { input: Some(content), entries: Vec::new() };
239	                    current_started = true;
240	                }
241	                Marker::Agent { content } => {
242	                    if !content.is_empty() {
243	                        current.entries.push(DigestEntry::Agent(content));
244	                        current_started = true;
245	                    }
246	                }
247	                Marker::Tool(tu) => {
248	                    // Collapse into the trailing sprint if the last entry is one.
249	                    if let Some(DigestEntry::ToolSprint(sprint)) = current.entries.last_mut() {
250	                        sprint.push(tu);
251	                    } else {
252	                        current.entries.push(DigestEntry::ToolSprint(vec![tu]));
253	                    }
254	                    current_started = true;
255	                }
256	            }
257	        }
258	        if current_started || !current.entries.is_empty() || current.input.is_some() {
259	            turns.push(current);
260	        }
261
262	        // Keep only the last N turns.
263	        let n = self.config.window_turns;
264	        if turns.len() > n {
265	            turns.drain(..turns.len() - n);
266	        }
267	        Digest { turns }
268	    }
269
270	    /// Locate every pattern hit in the buffer, sorted by position, and resolve
271	    /// boundary span content (text from a boundary to the next hit of any kind).
272	    fn scan_markers(&self) -> Vec<Marker> {
273	        // (start, end, kind) where kind carries the extracted payload.
274	        enum Kind {
275	            Input,
276	            Agent,
277	            Tool(ToolUse),
278	        }
279	        let mut hits: Vec<(usize, usize, Kind)> = Vec::new();
280
281	        for m in self.input_re.find_iter(&self.buf) {
282	            hits.push((m.start(), m.end(), Kind::Input));
283	        }
284	        for m in self.agent_re.find_iter(&self.buf) {
285	            hits.push((m.start(), m.end(), Kind::Agent));
286	        }
287	        let mut push_tool = |re: &Regex| {
288	            for caps in re.captures_iter(&self.buf) {
289	                let whole = caps.get(0).unwrap();
290	                let name = caps
291	                    .name("name")
292	                    .map(|m| m.as_str().to_string())
293	                    .unwrap_or_else(|| {
294	                        // No name group: lead with the matched text's first token.
295	                        whole.as_str().split_whitespace().next().unwrap_or("").to_string()
296	                    });
297	                let arg_raw = caps.name("arg").map(|m| m.as_str()).unwrap_or("");
298	                let arg = truncate_arg(arg_raw.trim(), self.config.arg_truncation);
299	                hits.push((whole.start(), whole.end(), Kind::Tool(ToolUse { name, arg })));
300	            }
301	        };
302	        for re in &self.tool_res {
303	            push_tool(re);
304	        }
305	        if let Some(re) = &self.catchall_re {
306	            // Only count a catchall hit where no specific tool already matched.
307	            for caps in re.captures_iter(&self.buf) {
308	                let whole = caps.get(0).unwrap();
309	                if hits.iter().any(|(s, e, k)| {
310	                    matches!(k, Kind::Tool(_)) && *s <= whole.start() && whole.end() <= *e
311	                }) {
312	                    continue;
313	                }
314	                let name = caps
315	                    .name("name")
316	                    .map(|m| m.as_str().to_string())
317	                    .unwrap_or_else(|| {
318	                        whole.as_str().split_whitespace().next().unwrap_or("").to_string()
319	                    });
320	                let arg_raw = caps.name("arg").map(|m| m.as_str()).unwrap_or("");
321	                let arg = truncate_arg(arg_raw.trim(), self.config.arg_truncation);
322	                hits.push((whole.start(), whole.end(), Kind::Tool(ToolUse { name, arg })));
323	            }
324	        }
325
326	        // Order by position; on a tie, boundaries before tools (a boundary opens
327	        // a span, tools sit inside it).
328	        let rank = |k: &Kind| -> u8 { matches!(k, Kind::Tool(_)) as u8 };
329	        hits.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| rank(&a.2).cmp(&rank(&b.2))));
330
331	        // Resolve boundary span content = text from this hit's end to the next
332	        // hit's start.
333	        let starts: Vec<usize> = hits.iter().map(|(s, _, _)| *s).collect();
334	        let mut out = Vec::with_capacity(hits.len());
335	        for (i, (_, end, kind)) in hits.iter().enumerate() {
336	            let next_start = starts.get(i + 1).copied().unwrap_or(self.buf.len());
337	            let span = self.buf.get(*end..next_start).unwrap_or("").trim().to_string();
338	            match kind {
339	                Kind::Input => out.push(Marker::Input { content: span }),
340	                Kind::Agent => out.push(Marker::Agent { content: span }),
341	                Kind::Tool(tu) => out.push(Marker::Tool(tu.clone())),
342	            }
343	        }
344	        out
345	    }
346	}
347
348	/// Truncate `arg` to `width` characters, appending `…` when shortened.
349	fn truncate_arg(arg: &str, width: usize) -> String {
350	    if arg.chars().count() <= width {
351	        return arg.to_string();
352	    }
353	    let mut s: String = arg.chars().take(width).collect();
354	    s.push('…');
355	    s
356	}
357
358	#[cfg(test)]
359	mod tests {
360	    use super::*;
361
362	    fn patterns() -> DigestPatterns {
363	        DigestPatterns {
364	            // Boundary markers (the prompt sigils an adapter would supply).
365	            input: r"\nUSER> ".to_string(),
366	            agent: r"\nAGENT> ".to_string(),
367	            tools: vec![
368	                r"(?P<name>Write)\((?P<arg>[^)]*)\)".to_string(),
369	                r"(?P<name>Bash)\((?P<arg>[^)]*)\)".to_string(),
370	            ],
371	            catchall: Some(r"(?P<name>[A-Z][a-zA-Z]+)\((?P<arg>[^)]*)\)".to_string()),
372	        }
373	    }
374
375	    fn parser() -> DigestParser {
376	        DigestParser::new(patterns(), DigestConfig { window_turns: 3, ..Default::default() })
377	            .expect("compile patterns")
378	    }
379
380	    // [unit->REQ-TERM-4]
381	    #[test]
382	    fn tags_input_agent_and_collapses_tool_sprints() {
383	        let mut p = parser();
384	        p.feed(b"\nUSER> add a file\nAGENT> sure, doing it\nWrite(src/a.rs) Bash(cargo build) done\n");
385	        let d = p.digest();
386	        assert_eq!(d.turns.len(), 1);
387	        let t = &d.turns[0];
388	        assert_eq!(t.input.as_deref(), Some("add a file"));
389	        // First entry: agent text; second: a single collapsed sprint of 2 tools.
390	        assert_eq!(t.entries[0], DigestEntry::Agent("sure, doing it".to_string()));
391	        match &t.entries[1] {
392	            DigestEntry::ToolSprint(s) => {
393	                assert_eq!(s.len(), 2, "consecutive tools collapse into one sprint");
394	                assert_eq!(s[0], ToolUse { name: "Write".into(), arg: "src/a.rs".into() });
395	                assert_eq!(s[1], ToolUse { name: "Bash".into(), arg: "cargo build".into() });
396	            }
397	            other => panic!("expected a tool sprint, got {other:?}"),
398	        }
399	    }
400
401	    // [unit->REQ-TERM-4]
402	    #[test]
403	    fn windows_to_last_n_turns() {
404	        let mut p = parser();
405	        for i in 0..6 {
406	            p.feed(format!("\nUSER> turn {i}\nAGENT> reply {i}\n").as_bytes());
407	        }
408	        let d = p.digest();
409	        assert_eq!(d.turns.len(), 3, "window keeps only the last N=3 turns");
410	        assert_eq!(d.turns[0].input.as_deref(), Some("turn 3"));
411	        assert_eq!(d.turns[2].input.as_deref(), Some("turn 5"));
412	    }
413
414	    // [unit->REQ-TERM-4]
415	    #[test]
416	    fn truncates_tool_args_to_width() {
417	        let cfg = DigestConfig { window_turns: 3, arg_truncation: 10, ..Default::default() };
418	        let mut p = DigestParser::new(patterns(), cfg).unwrap();
419	        p.feed(b"\nUSER> x\nAGENT> y\nBash(this-is-a-very-long-command-line) z\n");
420	        let d = p.digest();
421	        let sprint = d.turns[0]
422	            .entries
423	            .iter()
424	            .find_map(|e| match e {
425	                DigestEntry::ToolSprint(s) => Some(s),
426	                _ => None,
427	            })
428	            .expect("a sprint");
429	        assert_eq!(sprint[0].name, "Bash");
430	        assert_eq!(sprint[0].arg, "this-is-a-…", "arg truncated to width with ellipsis");
431	    }
432
433	    // [unit->REQ-TERM-4]
434	    #[test]
435	    fn strips_ansi_and_tolerates_repaint_duplication() {
436	        let mut p = parser();
437	        // A ConPTY-style repaint: clear + home + a duplicated earlier turn, then
438	        // the real latest turn. ANSI is stripped; the tail (last N) wins.
439	        p.feed(b"\nUSER> first\nAGENT> \x1b[2J\x1b[Hreply one\n");
440	        p.feed(b"\x1b[2J\x1b[H\nUSER> first\nAGENT> reply one\n\nUSER> second\nAGENT> reply two\x1b[0m\n");
441	        let d = p.digest();
442	        // No ANSI leaked into content.
443	        for t in &d.turns {
444	            if let Some(i) = &t.input {
445	                assert!(!i.contains('\x1b'), "ANSI stripped from input");
446	            }
447	            for e in &t.entries {
448	                if let DigestEntry::Agent(a) = e {
449	                    assert!(!a.contains('\x1b'), "ANSI stripped from agent text");
450	                    assert!(!a.contains("[2J"), "no CSI residue");
451	                }
452	            }
453	        }
454	        // The most recent turn is present and correct despite the repaint.
455	        let last = d.turns.last().unwrap();
456	        assert_eq!(last.input.as_deref(), Some("second"));
457	        assert_eq!(last.entries[0], DigestEntry::Agent("reply two".to_string()));
458	    }
459
460	    // [unit->REQ-TERM-4]
461	    #[test]
462	    fn bad_pattern_is_reported_by_name() {
463	        let mut pats = patterns();
464	        pats.agent = r"(unclosed".to_string();
465	        let err = DigestParser::new(pats, DigestConfig::default()).unwrap_err();
466	        match err {
467	            DigestError::BadPattern { which, .. } => assert_eq!(which, "agent"),
468	        }
469	    }
470	}
471

//! Live activity buffer (PTY digest) parser primitive (M3a A5, ADR-0008).
//!
//! A rolling, *parsed*, source-tagged view of recent PTY I/O — the last ~N user
//! turns plus the agent output between them, with tool-usage sprints collapsed
//! to a single entry. Distinct from raw scrollback (bytes) and transcript
//! history (the full conversation); its job is *"what is this agent doing right
//! now, at a glance."*
//!
//! ## The parsing-boundary rule (ADR-0008 — NOT a built-in per-harness parser)
//!
//! **The adapter supplies the patterns; this primitive only runs them.** Exactly
//! Path A's "adapter owns the parser" rule and the "spt-core owns the mechanism,
//! adapter owns the pattern" principle (identical to opaque command templates).
//! The seam is three adapter-supplied pattern kinds:
//! - **`input`** (user-input boundary) and **`agent`** (agent-output boundary) —
//!   each *opens a span capturing everything, newlines included, until the next
//!   pattern match of any kind*.
//! - **`tools`** — a list (one regex per known tool) **plus an optional
//!   `catchall`** for unknowable user-installed tools — each extracting a tool
//!   `name` and an `arg` fragment via named capture groups.
//!
//! Window depth `N` and the tool-arg truncation width are spt-core-controlled
//! *presentation*; the template controls *extraction*.
//!
//! ## Scope (M3a is the primitive ONLY)
//!
//! This is the parser: patterns in, structured [`Digest`] out, fed from the A4
//! byte stream. The manifest-sourcing of the patterns, the `spt digest` pull,
//! the broker-pushed delta-stream, and Path-B persistence are **M3b** — they need
//! the broker + manifest + history store this crate deliberately does not build.
//!
//! ## OS-neutrality (Spike #4 / #5)
//!
//! ConPTY repaints on resize — it reorders and *duplicates* stream content. The
//! parser therefore re-derives the digest from a bounded rolling text buffer and
//! reports the **tail** (last `N` turns): a repaint that resends a turn just
//! makes the latest copy win. ANSI control sequences (cursor moves, clears,
//! repaint machinery) are stripped before matching so the adapter patterns see
//! clean text on both the forkpty raw-pipe and ConPTY screen-buffer backends.

use regex::Regex;

/// A failure compiling an adapter-supplied pattern.
#[derive(Debug)]
pub enum DigestError {
    /// A pattern did not compile as a regex. Names which one.
    BadPattern { which: String, error: String },
}

impl std::fmt::Display for DigestError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            DigestError::BadPattern { which, error } => {
                write!(f, "digest pattern {which} did not compile: {error}")
            }
        }
    }
}

impl std::error::Error for DigestError {}

/// The adapter-supplied patterns (regex source strings). `input` / `agent` are
/// boundary markers; `tools` + `catchall` extract `name` + `arg` capture groups.
#[derive(Debug, Clone)]
pub struct DigestPatterns {
    /// User-input boundary marker.
    pub input: String,
    /// Agent-output boundary marker.
    pub agent: String,
    /// One regex per known tool; each should name an `arg` group (and may name a
    /// `name` group — else the tool's matched text leads).
    pub tools: Vec<String>,
    /// Optional final catchall for user-installed tools.
    pub catchall: Option<String>,
}

/// Presentation knobs owned by spt-core (not the template).
#[derive(Debug, Clone, Copy)]
pub struct DigestConfig {
    /// Window depth — keep the last `window_turns` user turns (~3).
    pub window_turns: usize,
    /// Tool-arg truncation width in characters (~25).
    pub arg_truncation: usize,
    /// Strip ANSI control sequences before matching (default on — see module docs).
    pub strip_ansi: bool,
    /// Rolling raw-text buffer cap in bytes; older bytes are dropped from the
    /// front so memory stays bounded under an endless stream.
    pub max_scan_bytes: usize,
}

impl Default for DigestConfig {
    fn default() -> Self {
        Self { window_turns: 3, arg_truncation: 25, strip_ansi: true, max_scan_bytes: 64 * 1024 }
    }
}

/// A single collapsed tool invocation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolUse {
    /// The tool name (e.g. `Write`).
    pub name: String,
    /// The (truncated) argument fragment.
    pub arg: String,
}

/// One source-tagged entry within a turn.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DigestEntry {
    /// Agent output text (between boundaries).
    Agent(String),
    /// A collapsed run of consecutive tool invocations (one "sprint").
    ToolSprint(Vec<ToolUse>),
}

/// One user turn: the user input that opened it (None for any pre-input
/// preamble) plus the agent output / tool sprints that followed, in order.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Turn {
    /// The user input text that opened this turn.
    pub input: Option<String>,
    /// Agent + tool-sprint entries that followed, in stream order.
    pub entries: Vec<DigestEntry>,
}

/// The structured rolling digest — the last `N` turns.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Digest {
    /// Turns oldest-to-newest, at most `window_turns` of them.
    pub turns: Vec<Turn>,
}

/// One located pattern hit while scanning the buffer.
enum Marker {
    Input { content: String },
    Agent { content: String },
    Tool(ToolUse),
}

/// The digest parser: feed bytes from the A4 stream, read a structured [`Digest`].
#[derive(Debug)]
pub struct DigestParser {
    input_re: Regex,
    agent_re: Regex,
    tool_res: Vec<Regex>,
    catchall_re: Option<Regex>,
    ansi_re: Option<Regex>,
    config: DigestConfig,
    /// Rolling, ANSI-stripped text buffer.
    buf: String,
}

impl DigestParser {
    /// Compile the adapter-supplied patterns. Returns [`DigestError::BadPattern`]
    /// naming the first pattern that fails to compile.
    //
    // [impl->REQ-TERM-4]
    pub fn new(patterns: DigestPatterns, config: DigestConfig) -> Result<Self, DigestError> {
        let compile = |src: &str, which: &str| {
            Regex::new(src).map_err(|e| DigestError::BadPattern {
                which: which.to_string(),
                error: e.to_string(),
            })
        };
        let input_re = compile(&patterns.input, "input")?;
        let agent_re = compile(&patterns.agent, "agent")?;
        let mut tool_res = Vec::with_capacity(patterns.tools.len());
        for (i, t) in patterns.tools.iter().enumerate() {
            tool_res.push(compile(t, &format!("tools[{i}]"))?);
        }
        let catchall_re = match &patterns.catchall {
            Some(c) => Some(compile(c, "catchall")?),
            None => None,
        };
        // CSI / OSC / lone-ESC stripper. Compiled by us, never fails.
        let ansi_re = if config.strip_ansi {
            Some(
                Regex::new(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]")
                    .expect("static ANSI regex compiles"),
            )
        } else {
            None
        };
        Ok(Self {
            input_re,
            agent_re,
            tool_res,
            catchall_re,
            ansi_re,
            config,
            buf: String::new(),
        })
    }

    /// Feed a chunk of the byte stream. Decoded lossily to UTF-8, ANSI-stripped
    /// (if configured), normalized (`\r\n`/`\r` → `\n`), appended, and trimmed to
    /// the rolling cap.
    //
    // [impl->REQ-TERM-4]
    pub fn feed(&mut self, bytes: &[u8]) {
        let text = String::from_utf8_lossy(bytes);
        let cleaned = match &self.ansi_re {
            Some(re) => re.replace_all(&text, "").into_owned(),
            None => text.into_owned(),
        };
        // Normalize carriage returns left by ConPTY repaint so boundary patterns
        // and span content match on line-oriented text.
        let cleaned = cleaned.replace("\r\n", "\n").replace('\r', "");
        self.buf.push_str(&cleaned);

        if self.buf.len() > self.config.max_scan_bytes {
            // Drop from the front, on a char boundary.
            let cut = self.buf.len() - self.config.max_scan_bytes;
            let mut idx = cut;
            while idx < self.buf.len() && !self.buf.is_char_boundary(idx) {
                idx += 1;
            }
            self.buf.drain(..idx);
        }
    }

    /// Re-derive the structured digest from the current buffer: scan all pattern
    /// hits in order, fold boundary spans + collapsed tool sprints into turns,
    /// and return the last `window_turns`.
    //
    // [impl->REQ-TERM-4]
    pub fn digest(&self) -> Digest {
        let markers = self.scan_markers();
        let mut turns: Vec<Turn> = Vec::new();
        let mut current = Turn::default();
        let mut current_started = false;

        for m in markers {
            match m {
                Marker::Input { content } => {
                    if current_started || !current.entries.is_empty() || current.input.is_some() {
                        turns.push(std::mem::take(&mut current));
                    }
                    current = Turn { input: Some(content), entries: Vec::new() };
                    current_started = true;
                }
                Marker::Agent { content } => {
                    if !content.is_empty() {
                        current.entries.push(DigestEntry::Agent(content));
                        current_started = true;
                    }
                }
                Marker::Tool(tu) => {
                    // Collapse into the trailing sprint if the last entry is one.
                    if let Some(DigestEntry::ToolSprint(sprint)) = current.entries.last_mut() {
                        sprint.push(tu);
                    } else {
                        current.entries.push(DigestEntry::ToolSprint(vec![tu]));
                    }
                    current_started = true;
                }
            }
        }
        if current_started || !current.entries.is_empty() || current.input.is_some() {
            turns.push(current);
        }

        // Keep only the last N turns.
        let n = self.config.window_turns;
        if turns.len() > n {
            turns.drain(..turns.len() - n);
        }
        Digest { turns }
    }

    /// Locate every pattern hit in the buffer, sorted by position, and resolve
    /// boundary span content (text from a boundary to the next hit of any kind).
    fn scan_markers(&self) -> Vec<Marker> {
        // (start, end, kind) where kind carries the extracted payload.
        enum Kind {
            Input,
            Agent,
            Tool(ToolUse),
        }
        let mut hits: Vec<(usize, usize, Kind)> = Vec::new();

        for m in self.input_re.find_iter(&self.buf) {
            hits.push((m.start(), m.end(), Kind::Input));
        }
        for m in self.agent_re.find_iter(&self.buf) {
            hits.push((m.start(), m.end(), Kind::Agent));
        }
        let mut push_tool = |re: &Regex| {
            for caps in re.captures_iter(&self.buf) {
                let whole = caps.get(0).unwrap();
                let name = caps
                    .name("name")
                    .map(|m| m.as_str().to_string())
                    .unwrap_or_else(|| {
                        // No name group: lead with the matched text's first token.
                        whole.as_str().split_whitespace().next().unwrap_or("").to_string()
                    });
                let arg_raw = caps.name("arg").map(|m| m.as_str()).unwrap_or("");
                let arg = truncate_arg(arg_raw.trim(), self.config.arg_truncation);
                hits.push((whole.start(), whole.end(), Kind::Tool(ToolUse { name, arg })));
            }
        };
        for re in &self.tool_res {
            push_tool(re);
        }
        if let Some(re) = &self.catchall_re {
            // Only count a catchall hit where no specific tool already matched.
            for caps in re.captures_iter(&self.buf) {
                let whole = caps.get(0).unwrap();
                if hits.iter().any(|(s, e, k)| {
                    matches!(k, Kind::Tool(_)) && *s <= whole.start() && whole.end() <= *e
                }) {
                    continue;
                }
                let name = caps
                    .name("name")
                    .map(|m| m.as_str().to_string())
                    .unwrap_or_else(|| {
                        whole.as_str().split_whitespace().next().unwrap_or("").to_string()
                    });
                let arg_raw = caps.name("arg").map(|m| m.as_str()).unwrap_or("");
                let arg = truncate_arg(arg_raw.trim(), self.config.arg_truncation);
                hits.push((whole.start(), whole.end(), Kind::Tool(ToolUse { name, arg })));
            }
        }

        // Order by position; on a tie, boundaries before tools (a boundary opens
        // a span, tools sit inside it).
        let rank = |k: &Kind| -> u8 { matches!(k, Kind::Tool(_)) as u8 };
        hits.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| rank(&a.2).cmp(&rank(&b.2))));

        // Resolve boundary span content = text from this hit's end to the next
        // hit's start.
        let starts: Vec<usize> = hits.iter().map(|(s, _, _)| *s).collect();
        let mut out = Vec::with_capacity(hits.len());
        for (i, (_, end, kind)) in hits.iter().enumerate() {
            let next_start = starts.get(i + 1).copied().unwrap_or(self.buf.len());
            let span = self.buf.get(*end..next_start).unwrap_or("").trim().to_string();
            match kind {
                Kind::Input => out.push(Marker::Input { content: span }),
                Kind::Agent => out.push(Marker::Agent { content: span }),
                Kind::Tool(tu) => out.push(Marker::Tool(tu.clone())),
            }
        }
        out
    }
}

/// Truncate `arg` to `width` characters, appending `…` when shortened.
fn truncate_arg(arg: &str, width: usize) -> String {
    if arg.chars().count() <= width {
        return arg.to_string();
    }
    let mut s: String = arg.chars().take(width).collect();
    s.push('…');
    s
}

#[cfg(test)]
mod tests {
    use super::*;

    fn patterns() -> DigestPatterns {
        DigestPatterns {
            // Boundary markers (the prompt sigils an adapter would supply).
            input: r"\nUSER> ".to_string(),
            agent: r"\nAGENT> ".to_string(),
            tools: vec![
                r"(?P<name>Write)\((?P<arg>[^)]*)\)".to_string(),
                r"(?P<name>Bash)\((?P<arg>[^)]*)\)".to_string(),
            ],
            catchall: Some(r"(?P<name>[A-Z][a-zA-Z]+)\((?P<arg>[^)]*)\)".to_string()),
        }
    }

    fn parser() -> DigestParser {
        DigestParser::new(patterns(), DigestConfig { window_turns: 3, ..Default::default() })
            .expect("compile patterns")
    }

    // [unit->REQ-TERM-4]
    #[test]
    fn tags_input_agent_and_collapses_tool_sprints() {
        let mut p = parser();
        p.feed(b"\nUSER> add a file\nAGENT> sure, doing it\nWrite(src/a.rs) Bash(cargo build) done\n");
        let d = p.digest();
        assert_eq!(d.turns.len(), 1);
        let t = &d.turns[0];
        assert_eq!(t.input.as_deref(), Some("add a file"));
        // First entry: agent text; second: a single collapsed sprint of 2 tools.
        assert_eq!(t.entries[0], DigestEntry::Agent("sure, doing it".to_string()));
        match &t.entries[1] {
            DigestEntry::ToolSprint(s) => {
                assert_eq!(s.len(), 2, "consecutive tools collapse into one sprint");
                assert_eq!(s[0], ToolUse { name: "Write".into(), arg: "src/a.rs".into() });
                assert_eq!(s[1], ToolUse { name: "Bash".into(), arg: "cargo build".into() });
            }
            other => panic!("expected a tool sprint, got {other:?}"),
        }
    }

    // [unit->REQ-TERM-4]
    #[test]
    fn windows_to_last_n_turns() {
        let mut p = parser();
        for i in 0..6 {
            p.feed(format!("\nUSER> turn {i}\nAGENT> reply {i}\n").as_bytes());
        }
        let d = p.digest();
        assert_eq!(d.turns.len(), 3, "window keeps only the last N=3 turns");
        assert_eq!(d.turns[0].input.as_deref(), Some("turn 3"));
        assert_eq!(d.turns[2].input.as_deref(), Some("turn 5"));
    }

    // [unit->REQ-TERM-4]
    #[test]
    fn truncates_tool_args_to_width() {
        let cfg = DigestConfig { window_turns: 3, arg_truncation: 10, ..Default::default() };
        let mut p = DigestParser::new(patterns(), cfg).unwrap();
        p.feed(b"\nUSER> x\nAGENT> y\nBash(this-is-a-very-long-command-line) z\n");
        let d = p.digest();
        let sprint = d.turns[0]
            .entries
            .iter()
            .find_map(|e| match e {
                DigestEntry::ToolSprint(s) => Some(s),
                _ => None,
            })
            .expect("a sprint");
        assert_eq!(sprint[0].name, "Bash");
        assert_eq!(sprint[0].arg, "this-is-a-…", "arg truncated to width with ellipsis");
    }

    // [unit->REQ-TERM-4]
    #[test]
    fn strips_ansi_and_tolerates_repaint_duplication() {
        let mut p = parser();
        // A ConPTY-style repaint: clear + home + a duplicated earlier turn, then
        // the real latest turn. ANSI is stripped; the tail (last N) wins.
        p.feed(b"\nUSER> first\nAGENT> \x1b[2J\x1b[Hreply one\n");
        p.feed(b"\x1b[2J\x1b[H\nUSER> first\nAGENT> reply one\n\nUSER> second\nAGENT> reply two\x1b[0m\n");
        let d = p.digest();
        // No ANSI leaked into content.
        for t in &d.turns {
            if let Some(i) = &t.input {
                assert!(!i.contains('\x1b'), "ANSI stripped from input");
            }
            for e in &t.entries {
                if let DigestEntry::Agent(a) = e {
                    assert!(!a.contains('\x1b'), "ANSI stripped from agent text");
                    assert!(!a.contains("[2J"), "no CSI residue");
                }
            }
        }
        // The most recent turn is present and correct despite the repaint.
        let last = d.turns.last().unwrap();
        assert_eq!(last.input.as_deref(), Some("second"));
        assert_eq!(last.entries[0], DigestEntry::Agent("reply two".to_string()));
    }

    // [unit->REQ-TERM-4]
    #[test]
    fn bad_pattern_is_reported_by_name() {
        let mut pats = patterns();
        pats.agent = r"(unclosed".to_string();
        let err = DigestParser::new(pats, DigestConfig::default()).unwrap_err();
        match err {
            DigestError::BadPattern { which, .. } => assert_eq!(which, "agent"),
        }
    }
}


B8 is a large, multi-crate task (manifest `pty_digest` seam · broker-side `DigestParser` over the held PTY · IPC snapshot-pull + delta-stream · `spt digest <id>` CLI · opt-in Path-B persistence). B7 is a complete, committed, gated unit and I've already communed the B8 next-steps. Per the project's self-clear protocol, the clean move is to start B8 fresh with full context budget rather than half-start it here.

JIT plan for B8 captured. Self-clearing to resume on B8.


Skill

Resume on M3b-B8 (digest daemon-half, REQ-TERM-4 int). B7 orphan-watch DONE+committed (d06af5d, tree clean) — gate green (traceable-reqs 120/0, clippy -D, workspace 0 fail). Orient: M3b-PLAN.md B8 row; ADR-0008; the M3a DigestParser at crates/spt-term/src/digest.rs (patterns-in/Digest-out primitive, already built+unit-tested); the broker output stream (crates/spt-daemon/src/brain.rs read_event/OutputEvent + broker.rs). B8 scope: (1) manifest pty_digest seam — adapter supplies input/agent/tool patterns (mirror the opaque-command-template seam in spt-runtime/Manifest); (2) run DigestParser over the BROKER-held PTY stream (not a direct PTY); (3) `spt digest <id>` snapshot pull + broker-pushed structured delta-stream on the R-TERM-3 byte-stream substrate; (4) opt-in Path-B persistence appending to the history store. LOCAL addressing only — the qualified [subnet:]id@node form is M4, do NOT pull multi-subnet forward. REQ-TERM-4 is ["impl","unit"]; B8 supplies int. TRACEABILITY: tag impl/unit/int evidence same-commit, run `traceable-reqs check` before done. Don't regress spawn_detached handle-inheritance guard or the gapless-handoff seam. After B8: B9 (activation sweep + daemon E2E). Full context in the latest doyle-commune ingest.


Launching skill: self-clear


Base directory for this skill: C:\Users\decid\.claude\skills\self-clear

# /self-clear

Wipes THIS agent's conversation (`/clear`), then ~10s later TYPES a self-note
as a fresh user prompt so the agent wakes on a clean context and proceeds.

## Run it

One script does everything. Just run:

```powershell
& "C:\Users\decid\.claude\skills\self-clear\scripts\self-clear.ps1"
```

Expect: `SELF-CLEAR armed: id=<agent_id> winpid=<n> hwnd=<n> — /clear queued; self-note typed in ~10s`

Then stop — end your turn promptly so the queued `/clear` fires.

## What the script does

1. `owl whoami` → resolves this session's `agent_id` (context-independent, survives the wipe). No perch → it aborts WITHOUT clearing and tells you to `/spt:ready` or `/spt:live` first.
2. Walks the parent chain (`pwsh → claude.exe → … → WindowsTerminal`) to the ancestor that owns the window, foregrounds it.
3. Types `/clear` + Enter — **queued**; only fires when this turn ends.
4. Spawns a **detached** waker: waits 10s (past turn-end + wipe), re-foregrounds the terminal via the arm-time `hwnd`/`winpid`, then **types** `NOTE FROM THE USER: proceed with next steps.` + Enter as a fresh user prompt.

The 10s delay + detachment are deliberate: the prompt must be typed **after**
`/clear` takes effect, so it lands on the clean context and starts a new turn.

Why typed, not `owl send`: after `/clear` the session sits idle awaiting user
input. A spooled owl message can't self-start a turn — it surfaces only during an
already-active turn (PreToolUse hook) or in a live Monitor stream (whose buffer
`/clear` wipes). So a self-addressed owl note never woke the agent. A typed prompt
deterministically starts a turn AND is the re-seed.

## Caveats

- Window focus brings the whole terminal forward; it can't target a specific tab. Clean when the agent's tab is active.
- The note is typed via SendKeys, so the terminal must stay the foreground-able window of `winpid`. Don't close/replace the terminal in the ~10s gap.
- SendKeys reserves `+ ^ % ~ ( ) { } [ ]`; the default note text uses none. If you customize the note, escape those chars.
- If your turn runs longer than ~10s after the script returns, the prompt could be typed before the wipe completes. Stop promptly after running.

ARGUMENTS: Resume on M3b-B8 (digest daemon-half, REQ-TERM-4 int). B7 orphan-watch DONE+committed (d06af5d, tree clean) — gate green (traceable-reqs 120/0, clippy -D, workspace 0 fail). Orient: M3b-PLAN.md B8 row; ADR-0008; the M3a DigestParser at crates/spt-term/src/digest.rs (patterns-in/Digest-out primitive, already built+unit-tested); the broker output stream (crates/spt-daemon/src/brain.rs read_event/OutputEvent + broker.rs). B8 scope: (1) manifest pty_digest seam — adapter supplies input/agent/tool patterns (mirror the opaque-command-template seam in spt-runtime/Manifest); (2) run DigestParser over the BROKER-held PTY stream (not a direct PTY); (3) `spt digest <id>` snapshot pull + broker-pushed structured delta-stream on the R-TERM-3 byte-stream substrate; (4) opt-in Path-B persistence appending to the history store. LOCAL addressing only — the qualified [subnet:]id@node form is M4, do NOT pull multi-subnet forward. REQ-TERM-4 is ["impl","unit"]; B8 supplies int. TRACEABILITY: tag impl/unit/int evidence same-commit, run `traceable-reqs check` before done. Don't regress spawn_detached handle-inheritance guard or the gapless-handoff seam. After B8: B9 (activation sweep + daemon E2E). Full context in the latest doyle-commune ingest.


/clear


Running the self-clear script (default note; B8 context lives in the just-dropped commune + psyche state).


PowerShell

Run self-clear script


SELF-CLEAR armed: id=doyle winpid=29328 hwnd=133338 — /clear queued; self-note typed in ~10s


Armed — `/clear` queued, self-note fires in ~10s. Ending turn now so the wipe lands.

**B7 shipped** (`d06af5d`): orphan-watch supervised-crash signoff. REQ-DAEMON-4 `["impl","unit"]`. Gate green. Fresh context resumes on **B8** (digest daemon-half).