        // `remembered` `attach`, no grant → blocked, ask names the scope.
        let ask = act_gate_decide(&GrantStore::default(), &shell, "attach", "gw", node, None)
            .expect("ungranted attach blocks");
        assert_eq!(ask.capability, "shell-act:attach");
        assert_eq!(ask.agent, "gw");
        assert_eq!(ask.qualifier.as_deref(), Some("hid"));

        // `remembered` `attach`, no grant → blocked, ask names the scope, and
        // allow-always may persist (remembered).
        let (ask, persist) =
            act_gate_decide(&GrantStore::default(), &shell, "attach", "gw", node, None)
                .expect("ungranted attach blocks");
        assert_eq!(ask.capability, "shell-act:attach");
        assert_eq!(ask.agent, "gw");
        assert_eq!(ask.qualifier.as_deref(), Some("hid"));
        assert!(persist, "remembered mode allows persisting allow-always");

assert!(act_gate_decide(&GrantStore::default(), &shell, "list", "gw", node, None).is_none());

-        // `remembered` `attach`, no grant → blocked, ask names the scope.

-        let ask = act_gate_decide(&GrantStore::default(), &shell, "attach", "gw", node, None)

-            .expect("ungranted attach blocks");

+        // `remembered` `attach`, no grant → blocked, ask names the scope, and

+        // allow-always may persist (remembered).

+            act_gate_decide(&GrantStore::default(), &shell, "attach", "gw", node, None)

+                .expect("ungranted attach blocks");

+        assert!(persist, "remembered mode allows persisting allow-always");

// A grant for the HID class passes; a storage-class grant does NOT.


Edit

        // `always` `wipe` prompts even with a matching grant row.
        let mut s2 = GrantStore::default();
        s2.grant("shell-act:wipe", "gw", node, None);
        assert!(
            act_gate_decide(&s2, &shell, "wipe", "gw", node, None).is_some(),
            "always-mode prompts unconditionally"
        );
    }

        // `always` `wipe` prompts even with a matching grant row, and
        // allow-always is suppressed (persist=false).
        let mut s2 = GrantStore::default();
        s2.grant("shell-act:wipe", "gw", node, None);
        let (_, persist) = act_gate_decide(&s2, &shell, "wipe", "gw", node, None)
            .expect("always-mode prompts unconditionally");
        assert!(!persist, "always mode suppresses allow-always");
    }


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

        // `always` `wipe` prompts even with a matching grant row.
        let mut s2 = GrantStore::default();
        s2.grant("shell-act:wipe", "gw", node, None);
        assert!(
            act_gate_decide(&s2, &shell, "wipe", "gw", node, None).is_some(),
            "always-mode prompts unconditionally"
        );
    }

        // `always` `wipe` prompts even with a matching grant row, and
        // allow-always is suppressed (persist=false).
        let mut s2 = GrantStore::default();
        s2.grant("shell-act:wipe", "gw", node, None);
        let (_, persist) = act_gate_decide(&s2, &shell, "wipe", "gw", node, None)
            .expect("always-mode prompts unconditionally");
        assert!(!persist, "always mode suppresses allow-always");
    }

-        // `always` `wipe` prompts even with a matching grant row.

+        // `always` `wipe` prompts even with a matching grant row, and

+        // allow-always is suppressed (persist=false).

-            act_gate_decide(&s2, &shell, "wipe", "gw", node, None).is_some(),

-            "always-mode prompts unconditionally"

+        let (_, persist) = act_gate_decide(&s2, &shell, "wipe", "gw", node, None)

+            .expect("always-mode prompts unconditionally");

+        assert!(!persist, "always mode suppresses allow-always");

// [unit->REQ-CONSENT-3] the gate holds in the serve action core: a gated


            let err = drive_shell(&owlery, "gw", &id, "note", &args[1..]).unwrap_err();
            assert!(
                matches!(err, ShellLinkError::NeedsApproval(_)),
                "gated op blocks: {err}"
            );

            let err = drive_shell(&owlery, "gw", &id, "note", &args[1..]).unwrap_err();
            assert!(
                matches!(err, ShellLinkError::NeedsApproval(_, false)),
                "gated op blocks (always-mode, no persist): {err}"
            );


            let err = drive_shell(&owlery, "gw", &id, "note", &args[1..]).unwrap_err();
            assert!(
                matches!(err, ShellLinkError::NeedsApproval(_)),
                "gated op blocks: {err}"
            );

            let err = drive_shell(&owlery, "gw", &id, "note", &args[1..]).unwrap_err();
            assert!(
                matches!(err, ShellLinkError::NeedsApproval(_, false)),
                "gated op blocks (always-mode, no persist): {err}"
            );

let args = vec!["note".to_string(), "hi".to_string()];

-                matches!(err, ShellLinkError::NeedsApproval(_)),

-                "gated op blocks: {err}"

+                matches!(err, ShellLinkError::NeedsApproval(_, false)),

+                "gated op blocks (always-mode, no persist): {err}"

let rows = spt_store::spool::peek_all_at(&perch_path).unwrap();

assert!(rows.is_empty(), "a refused op must not spool");


CLI side — catch `NeedsApproval` in the `ShellCmd::Cmd` handler + add an `escalate_act` (mirror of `escalate_spawn`).


            match spt_daemon::drive_shell(&owlery, &owner, &shell_ref, op_name, args) {
                Ok((id, shell)) => {
                    drain_per_receipt(&owlery, &owner, &id, &shell, &format!("op={op_name}"))
                }
                Err(e) => render_link_err("SHELL_CMD", &shell_ref, &owner, e),
            }

            match spt_daemon::drive_shell(&owlery, &owner, &shell_ref, op_name, args) {
                Ok((id, shell)) => {
                    drain_per_receipt(&owlery, &owner, &id, &shell, &format!("op={op_name}"))
                }
                // Per-capability act-gate (M11, REQ-CONSENT-3): the gated op did
                // NOT spool — route the approval prompt (the same plumbing the
                // spawn gate uses) before the command may land.
                Err(spt_daemon::linkhost::ShellLinkError::NeedsApproval(ask, persist)) => {
                    escalate_act(&owlery, &ask, persist, &format!("op '{op_name}' is gated"))
                }
                Err(e) => render_link_err("SHELL_CMD", &shell_ref, &owner, e),
            }


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

            match spt_daemon::drive_shell(&owlery, &owner, &shell_ref, op_name, args) {
                Ok((id, shell)) => {
                    drain_per_receipt(&owlery, &owner, &id, &shell, &format!("op={op_name}"))
                }
                Err(e) => render_link_err("SHELL_CMD", &shell_ref, &owner, e),
            }

            match spt_daemon::drive_shell(&owlery, &owner, &shell_ref, op_name, args) {
                Ok((id, shell)) => {
                    drain_per_receipt(&owlery, &owner, &id, &shell, &format!("op={op_name}"))
                }
                // Per-capability act-gate (M11, REQ-CONSENT-3): the gated op did
                // NOT spool — route the approval prompt (the same plumbing the
                // spawn gate uses) before the command may land.
                Err(spt_daemon::linkhost::ShellLinkError::NeedsApproval(ask, persist)) => {
                    escalate_act(&owlery, &ask, persist, &format!("op '{op_name}' is gated"))
                }
                Err(e) => render_link_err("SHELL_CMD", &shell_ref, &owner, e),
            }

+                // Per-capability act-gate (M11, REQ-CONSENT-3): the gated op did

+                // NOT spool — route the approval prompt (the same plumbing the

+                // spawn gate uses) before the command may land.

+                Err(spt_daemon::linkhost::ShellLinkError::NeedsApproval(ask, persist)) => {

+                    escalate_act(&owlery, &ask, persist, &format!("op '{op_name}' is gated"))


    } else {
        eprintln!(
            "SHELL_APPROVAL_REQUIRED:{adapter}: {why} — per-spawn approval is interactive \
             by definition (never persisted); re-run from a terminal"
        );
        1
    }
}

    } else {
        eprintln!(
            "SHELL_APPROVAL_REQUIRED:{adapter}: {why} — per-spawn approval is interactive \
             by definition (never persisted); re-run from a terminal"
        );
        1
    }
}

/// The interactive affordance for a gated shell **capability** ask (M11,
/// REQ-CONSENT-3 — the per-capability act-gate). Mirrors [`escalate_spawn`]:
/// a TTY prompts `[o]nce / [a]lways / [d]eny` (allow-always offered only on a
/// `remembered` capability — `persist_allowed`), terminating through D1b's
/// `apply_escalation_answer`; a non-TTY `remembered` fires the consent notif
/// (whose body carries the durable `spt grant add …` path) and exits
/// `CONSENT_PENDING`; a non-TTY `always` refuses outright (per-act approval is
/// interactive by definition). Returns the exit code; `0` = proceed (the caller
/// re-drives, which now passes the gate). The grant qualifier is the class key
/// (`None` ⇒ the op id is the whole scope).
// [impl->REQ-CONSENT-3]
fn escalate_act(
    owlery: &std::path::Path,
    ask: &spt_daemon::grants::EscalationAsk,
    persist_allowed: bool,
    why: &str,
) -> i32 {
    use spt_daemon::grants::{apply_escalation_answer, EscalationOutcome};
    use std::io::IsTerminal;
    let class = ask.qualifier.as_deref().unwrap_or("—");
    if std::io::stdin().is_terminal() {
        let menu = if persist_allowed {
            "[o]nce / [a]lways / [d]eny"
        } else {
            "[o]nce / [d]eny"
        };
        eprintln!("SHELL_ACT_APPROVAL:{}: {why} (class {class}) — approve? {menu}", ask.capability);
        let mut line = String::new();
        let _ = std::io::stdin().read_line(&mut line);
        let mut store = spt_store::grants::GrantStore::load();
        match apply_escalation_answer(
            &mut store,
            ask,
            parse_escalation_choice(&line, persist_allowed),
        ) {
            EscalationOutcome::ProceedOnce => 0,
            EscalationOutcome::ProceedGranted => match store.save() {
                Ok(()) => {
                    eprintln!("GRANT_ADD:{}:{}:{class}", ask.capability, ask.agent);
                    0
                }
                Err(e) => {
                    eprintln!("GRANT_FAIL: {e}");
                    1
                }
            },
            EscalationOutcome::Denied => {
                eprintln!("SHELL_ACT_DENIED:{}: {why}", ask.capability);
                1
            }
        }
    } else if persist_allowed {
        fire_spawn_consent_notif(owlery, ask);
        let qual = ask
            .qualifier
            .as_deref()
            .map(|q| format!(" --qualifier {q}"))
            .unwrap_or_default();
        eprintln!(
            "CONSENT_PENDING:{}: {why} — answer the consent notif, or allow always: \
             `spt grant add {} {}{qual}`",
            ask.capability, ask.capability, ask.agent
        );
        1
    } else {
        eprintln!(
            "SHELL_APPROVAL_REQUIRED:{}: {why} — per-act approval is interactive by \
             definition (never persisted); re-run from a terminal",
            ask.capability
        );
        1
    }
}


write the M11 JIT plan, then verify it with doyle. proceed to execution after his greenlight


Write and verify M11 JIT plan with Doyle


    } else {
        eprintln!(
            "SHELL_APPROVAL_REQUIRED:{adapter}: {why} — per-spawn approval is interactive \
             by definition (never persisted); re-run from a terminal"
        );
        1
    }
}

/// The interactive affordance for a gated shell **capability** ask (M11,
/// REQ-CONSENT-3 — the per-capability act-gate). Mirrors [`escalate_spawn`]:
/// a TTY prompts `[o]nce / [a]lways / [d]eny` (allow-always offered only on a
/// `remembered` capability — `persist_allowed`), terminating through D1b's
/// `apply_escalation_answer`; a non-TTY `remembered` fires the consent notif
/// (whose body carries the durable `spt grant add …` path) and exits
/// `CONSENT_PENDING`; a non-TTY `always` refuses outright (per-act approval is
/// interactive by definition). Returns the exit code; `0` = proceed (the caller
/// re-drives, which now passes the gate). The grant qualifier is the class key
/// (`None` ⇒ the op id is the whole scope).
// [impl->REQ-CONSENT-3]
fn escalate_act(
    owlery: &std::path::Path,
    ask: &spt_daemon::grants::EscalationAsk,
    persist_allowed: bool,
    why: &str,
) -> i32 {
    use spt_daemon::grants::{apply_escalation_answer, EscalationOutcome};
    use std::io::IsTerminal;
    let class = ask.qualifier.as_deref().unwrap_or("—");
    if std::io::stdin().is_terminal() {
        let menu = if persist_allowed {
            "[o]nce / [a]lways / [d]eny"
        } else {
            "[o]nce / [d]eny"
        };
        eprintln!("SHELL_ACT_APPROVAL:{}: {why} (class {class}) — approve? {menu}", ask.capability);
        let mut line = String::new();
        let _ = std::io::stdin().read_line(&mut line);
        let mut store = spt_store::grants::GrantStore::load();
        match apply_escalation_answer(
            &mut store,
            ask,
            parse_escalation_choice(&line, persist_allowed),
        ) {
            EscalationOutcome::ProceedOnce => 0,
            EscalationOutcome::ProceedGranted => match store.save() {
                Ok(()) => {
                    eprintln!("GRANT_ADD:{}:{}:{class}", ask.capability, ask.agent);
                    0
                }
                Err(e) => {
                    eprintln!("GRANT_FAIL: {e}");
                    1
                }
            },
            EscalationOutcome::Denied => {
                eprintln!("SHELL_ACT_DENIED:{}: {why}", ask.capability);
                1
            }
        }
    } else if persist_allowed {
        fire_spawn_consent_notif(owlery, ask);
        let qual = ask
            .qualifier
            .as_deref()
            .map(|q| format!(" --qualifier {q}"))
            .unwrap_or_default();
        eprintln!(
            "CONSENT_PENDING:{}: {why} — answer the consent notif, or allow always: \
             `spt grant add {} {}{qual}`",
            ask.capability, ask.capability, ask.agent
        );
        1
    } else {
        eprintln!(
            "SHELL_APPROVAL_REQUIRED:{}: {why} — per-act approval is interactive by \
             definition (never persisted); re-run from a terminal",
            ask.capability
        );
        1
    }
}

+/// The interactive affordance for a gated shell **capability** ask (M11,

+/// REQ-CONSENT-3 — the per-capability act-gate). Mirrors [`escalate_spawn`]:

+/// a TTY prompts `[o]nce / [a]lways / [d]eny` (allow-always offered only on a

+/// `remembered` capability — `persist_allowed`), terminating through D1b's

+/// `apply_escalation_answer`; a non-TTY `remembered` fires the consent notif

+/// (whose body carries the durable `spt grant add …` path) and exits

+/// `CONSENT_PENDING`; a non-TTY `always` refuses outright (per-act approval is

+/// interactive by definition). Returns the exit code; `0` = proceed (the caller

+/// re-drives, which now passes the gate). The grant qualifier is the class key

+/// (`None` ⇒ the op id is the whole scope).

+    ask: &spt_daemon::grants::EscalationAsk,

+    use spt_daemon::grants::{apply_escalation_answer, EscalationOutcome};

+    let class = ask.qualifier.as_deref().unwrap_or("—");

+    if std::io::stdin().is_terminal() {

+        let menu = if persist_allowed {

+            "[o]nce / [a]lways / [d]eny"

+        eprintln!("SHELL_ACT_APPROVAL:{}: {why} (class {class}) — approve? {menu}", ask.capability);

+        let _ = std::io::stdin().read_line(&mut line);

+        let mut store = spt_store::grants::GrantStore::load();

+            parse_escalation_choice(&line, persist_allowed),

+            EscalationOutcome::ProceedOnce => 0,

+            EscalationOutcome::ProceedGranted => match store.save() {

+                    eprintln!("GRANT_ADD:{}:{}:{class}", ask.capability, ask.agent);

+                    eprintln!("GRANT_FAIL: {e}");

+            EscalationOutcome::Denied => {

+                eprintln!("SHELL_ACT_DENIED:{}: {why}", ask.capability);

+        fire_spawn_consent_notif(owlery, ask);

+            .map(|q| format!(" --qualifier {q}"))

+            "CONSENT_PENDING:{}: {why} — answer the consent notif, or allow always: \

+             `spt grant add {} {}{qual}`",

+            ask.capability, ask.capability, ask.agent

+            "SHELL_APPROVAL_REQUIRED:{}: {why} — per-act approval is interactive by \

+             definition (never persisted); re-run from a terminal",

/// This node's pubkey hex — non-minting (the daemon access-gate stance):

/// gating or authoring against a node with no identity is an error, not an

/// identity-creating side effect. Shared by the grant CLI and the spawn


Build both crates to check the new paths resolve.


Bash

cd C:/Users/decid/Documents/projects/spt-core && cargo build -p spt-daemon -p spt -q 2>&1 | tail -25; echo "EXIT=${PIPESTATUS[0]}"

Build daemon + cli with T1.2b


error[E0004]: non-exhaustive patterns: `ShellLinkError::NeedsApproval(_, _)` not covered
    --> crates\spt\src\cli.rs:5759:11
     |
5759 |     match e {
     |           ^ pattern `ShellLinkError::NeedsApproval(_, _)` not covered
     |
note: `ShellLinkError` defined here
    --> C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\linkhost.rs:61:1
     |
  61 | pub enum ShellLinkError {
     | ^^^^^^^^^^^^^^^^^^^^^^^
...
  84 |     NeedsApproval(EscalationAsk, bool),
     |     ------------- not covered
     = note: the matched value is of type `ShellLinkError`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
     |
5774 ~         E::Failed(msg) => eprintln!("{verb}_FAIL:{shell_ref}: {msg}"),
5775 ~         ShellLinkError::NeedsApproval(_, _) => todo!(),
     |

For more information about this error, try `rustc --explain E0004`.
error: could not compile `spt` (bin "spt") due to 1 previous error
EXIT=101


`render_link_err` matches exhaustively. Add a defensive `NeedsApproval` arm (the Cmd handler intercepts it first, but the match must cover it).


5750	            }
5751	        }
5752	    }
5753	}
5754
5755	/// Render a shared shell-link action error with the CLI's established message
5756	/// shapes (the daemon core returns typed errors; the strings stay here).
5757	fn render_link_err(verb: &str, shell_ref: &str, owner: &str, e: spt_daemon::ShellLinkError) -> i32 {
5758	    use spt_daemon::ShellLinkError as E;
5759	    match e {
5760	        E::NoShell => eprintln!("NO_SHELL:{shell_ref}: no such instance for {owner}"),
5761	        E::AdapterUnregistered(a) => eprintln!(
5762	            "SHELL_ADAPTER_UNREGISTERED:{shell_ref}: adapter {a} is not registered/active (spt adapter add)"
5763	        ),
5764	        E::ReceiptUnsupported => eprintln!(
5765	            "SHELL_RECEIPT_UNSUPPORTED:{shell_ref}: command_receipt=http is not implemented yet"
5766	        ),
5767	        E::NoLink => {
5768	            eprintln!("NO_LINK:{shell_ref}: no link token parked (instance never launched)")
5769	        }
5770	        E::Vocab(msg) => eprintln!("{verb}_REFUSED:{shell_ref}: {msg}"),
5771	        E::AlreadyOnline => eprintln!(
5772	            "SHELL_ALREADY_ONLINE:{shell_ref}: relink is the online switch — nothing to do"
5773	        ),
5774	        E::Failed(msg) => eprintln!("{verb}_FAIL:{shell_ref}: {msg}"),
5775	    }
5776	    1
5777	}
5778
5779	/// Run a shell-link action against the shell's node (M5-D8c): resolve the

            }
        }
    }
}

/// Render a shared shell-link action error with the CLI's established message
/// shapes (the daemon core returns typed errors; the strings stay here).
fn render_link_err(verb: &str, shell_ref: &str, owner: &str, e: spt_daemon::ShellLinkError) -> i32 {
    use spt_daemon::ShellLinkError as E;
    match e {
        E::NoShell => eprintln!("NO_SHELL:{shell_ref}: no such instance for {owner}"),
        E::AdapterUnregistered(a) => eprintln!(
            "SHELL_ADAPTER_UNREGISTERED:{shell_ref}: adapter {a} is not registered/active (spt adapter add)"
        ),
        E::ReceiptUnsupported => eprintln!(
            "SHELL_RECEIPT_UNSUPPORTED:{shell_ref}: command_receipt=http is not implemented yet"
        ),
        E::NoLink => {
            eprintln!("NO_LINK:{shell_ref}: no link token parked (instance never launched)")
        }
        E::Vocab(msg) => eprintln!("{verb}_REFUSED:{shell_ref}: {msg}"),
        E::AlreadyOnline => eprintln!(
            "SHELL_ALREADY_ONLINE:{shell_ref}: relink is the online switch — nothing to do"
        ),
        E::Failed(msg) => eprintln!("{verb}_FAIL:{shell_ref}: {msg}"),
    }
    1
}

/// Run a shell-link action against the shell's node (M5-D8c): resolve the


Edit

        E::Failed(msg) => eprintln!("{verb}_FAIL:{shell_ref}: {msg}"),
    }
    1
}

        E::Failed(msg) => eprintln!("{verb}_FAIL:{shell_ref}: {msg}"),
        // The act-gate is intercepted at the cmd call site (escalate_act); a
        // gated op reaching the generic renderer (e.g. via the remote path)
        // surfaces as a clear approval-required line, never a silent failure.
        E::NeedsApproval(ask, _) => eprintln!(
            "{verb}_NEEDS_APPROVAL:{shell_ref}: op '{}' is gated — approve on the shell's node",
            ask.capability
        ),
    }
    1
}


        E::Failed(msg) => eprintln!("{verb}_FAIL:{shell_ref}: {msg}"),
    }
    1
}

        E::Failed(msg) => eprintln!("{verb}_FAIL:{shell_ref}: {msg}"),
        // The act-gate is intercepted at the cmd call site (escalate_act); a
        // gated op reaching the generic renderer (e.g. via the remote path)
        // surfaces as a clear approval-required line, never a silent failure.
        E::NeedsApproval(ask, _) => eprintln!(
            "{verb}_NEEDS_APPROVAL:{shell_ref}: op '{}' is gated — approve on the shell's node",
            ask.capability
        ),
    }
    1
}

"SHELL_ALREADY_ONLINE:{shell_ref}: relink is the online switch — nothing to do"

+        // The act-gate is intercepted at the cmd call site (escalate_act); a

+        // gated op reaching the generic renderer (e.g. via the remote path)

+        // surfaces as a clear approval-required line, never a silent failure.

+        E::NeedsApproval(ask, _) => eprintln!(

+            "{verb}_NEEDS_APPROVAL:{shell_ref}: op '{}' is gated — approve on the shell's node",


Use the re-exported root path in the Cmd handler for consistency:


                Err(spt_daemon::linkhost::ShellLinkError::NeedsApproval(ask, persist)) => {
                    escalate_act(&owlery, &ask, persist, &format!("op '{op_name}' is gated"))
                }

                Err(spt_daemon::ShellLinkError::NeedsApproval(ask, persist)) => {
                    escalate_act(&owlery, &ask, persist, &format!("op '{op_name}' is gated"))
                }


                Err(spt_daemon::linkhost::ShellLinkError::NeedsApproval(ask, persist)) => {
                    escalate_act(&owlery, &ask, persist, &format!("op '{op_name}' is gated"))
                }

                Err(spt_daemon::ShellLinkError::NeedsApproval(ask, persist)) => {
                    escalate_act(&owlery, &ask, persist, &format!("op '{op_name}' is gated"))
                }

// Per-capability act-gate (M11, REQ-CONSENT-3): the gated op did

// NOT spool — route the approval prompt (the same plumbing the

// spawn gate uses) before the command may land.

-                Err(spt_daemon::linkhost::ShellLinkError::NeedsApproval(ask, persist)) => {

+                Err(spt_daemon::ShellLinkError::NeedsApproval(ask, persist)) => {

Err(e) => render_link_err("SHELL_CMD", &shell_ref, &owner, e),


Bash

cd C:/Users/decid/Documents/projects/spt-core && cargo build -p spt-daemon -p spt -q 2>&1 | tail -15; echo "EXIT=${PIPESTATUS[0]}"

Rebuild daemon + cli


Builds clean. Run the linkhost tests + cli shell tests.


cd C:/Users/decid/Documents/projects/spt-core && cargo test -p spt-daemon --lib linkhost 2>&1 | tail -10; echo "---cli shell---"; cargo test -p spt --lib shell 2>&1 | tail -12; echo "EXIT=${PIPESTATUS[0]}"

Run linkhost + cli shell unit tests


    Finished `test` profile [unoptimized + debuginfo] target(s) in 5.25s
     Running unittests src\lib.rs (target\debug\deps\spt_daemon-1ca149622a70a887.exe)

running 3 tests
test linkhost::tests::per_capability_act_gate_blocks_until_class_scoped_grant ... ok
test linkhost::tests::run_action_relinks_drives_and_refuses ... ok
test linkhost::tests::run_action_refuses_gated_op_without_spooling ... ok

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

---cli shell---
error: no library targets found in package `spt`
EXIT=101


`spt` is a bin not lib. Run via the bin target.


cd C:/Users/decid/Documents/projects/spt-core && cargo test -p spt --bin spt shell_spawn 2>&1 | tail -15; echo "EXIT=${PIPESTATUS[0]}"

Run cli shell-spawn unit tests (regression check)


   Compiling iroh v0.98.2
   Compiling spt-net v0.7.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt-net)
   Compiling spt-daemon v0.7.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
   Compiling spt v0.7.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 52.12s
     Running unittests src\main.rs (target\debug\deps\spt-a7ece24d03c0032a.exe)

running 4 tests
test cli::tests::shell_spawn_approval_floor_gates ... ok
test cli::tests::shell_spawn_gates_on_the_registered_set ... ok
test cli::tests::shell_spawn_cap_and_over_cap_gates ... ok
test cli::tests::shell_spawn_profiled_option_resolves_and_shares_cap ... ok

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

EXIT=0


Existing spawn-gate tests green — no regression. T1.2b complete (escalation firing: CLI `escalate_act` + serve `needs_approval` reply + render fallback). Commit it.


cd C:/Users/decid/Documents/projects/spt-core && traceable-reqs check >/dev/null 2>&1; echo "trace_exit=$?"; git branch --show-current; git add crates/spt-daemon/src/linkhost.rs crates/spt/src/cli.rs && git commit -q -F - <<'EOF'
M11-W1 T1.2b: act-gate escalation firing + render (REQ-CONSENT-3)

Wire the consent prompt onto the gate decision from T1.2a:

- NeedsApproval now carries persist_allowed (remembered ⇒ true, always ⇒
  false) so the front door knows whether allow-always may persist;
  act_gate_decide returns (EscalationAsk, persist_allowed).
- CLI cmd_shell (ShellCmd::Cmd): catches NeedsApproval → escalate_act, a sibling
  of escalate_spawn — TTY prompts [o]nce/[a]lways/[d]eny (allow-always only on
  remembered) through apply_escalation_answer; non-TTY remembered fires the
  consent notif + CONSENT_PENDING (carrying the `spt grant add` path, class
  qualifier); non-TTY always refuses (per-act approval is interactive). Reuses
  fire_spawn_consent_notif (generic — routes by the ask's home subnet).
- serve run_action SHELL_LINK_CMD: a distinct needs_approval reply — the gated
  op refuses to spool cross-node identically to local (grants are
  target-node-local; the operator approves on the shell's node, then re-drives).
  The interactive prompt is not routed over the wire (no remote TTY); flagged
  for doyle's wave gate whether cross-node notif-fire is wanted.
- render_link_err gains a defensive NeedsApproval arm (clear approval-required
  line, never a silent failure).

Unit (updated for the tuple): remembered ⇒ persist=true, always ⇒ persist=false
+ allow-always suppressed; gated op still never spools. linkhost 3/3 green;
existing spawn-gate cli tests 4/4 green; build clean; traceable EXIT 0.

REQ-CONSENT-3 activation + the interactive E2E (T1.3) close the wave next.

Co-authored by: todlando
EOF
echo "==="; git log --oneline -1

Commit T1.2b


trace_exit=0
m11-shell-substrate
===
db8b827 M11-W1 T1.2b: act-gate escalation firing + render (REQ-CONSENT-3)


T1.2b committed (db8b827) — escalation firing wired, all tests green. Wave 1 now ~85%: only T1.3 (interactive E2E) + REQ-CONSENT-3 activation remain to close it. That E2E is its own focused chunk — better as a clean next increment than rushed onto a long session.

Advanced one solid increment this tick. Rescheduling the loop (Monitor is the primary wake signal; this is the fallback heartbeat).


ScheduleWakeup

M11 W1 advancing; T1.2b committed, next is T1.3 interactive E2E + REQ-CONSENT-3 activation. Monitor is primary wake signal; this is the fallback heartbeat.

Prompt: <<autonomous-loop-dynamic>>


Next wakeup scheduled for 05:41:00 (in 1530s). Nothing more to do this turn — the harness re-invokes you when the wakeup fires or a task-notification arrives.


Tick complete. **T1.2b shipped** (db8b827): act-gate escalation firing wired through CLI `escalate_act` + serve `needs_approval` reply, all tests green, traceable EXIT 0. Wave 1 ~85% — next tick: T1.3 interactive E2E + activate REQ-CONSENT-3 to close the wave, then W2 (drive channel). Loop rescheduled.


<command-message>spt:commune</command-message>
<command-name>/spt:commune</command-name>
<command-args>with immediate next steps and broad summary of the project's status + end goal. we'll resume in the next session</command-args>


Base directory for this skill: C:\Users\decid\.ccs\instances\bigscreen\plugins\cache\cplugs\spt\1.11.25\skills\commune

# /spt:commune

All commands use `$LIVE` env var, auto-injected by the plugin's SessionStart hook. If commands fail with "command not found", restart the Claude Code session so the SessionStart hook re-runs.

> **Identity auto-detection:** Your identity is auto-detected from your session. Pass your ID explicitly only if auto-detection fails.

## Flow

1. **Download Psyche's *current* context** — it shifts during the session as background echo communes fire. Re-download right before composing; the gen-N snapshot you read at session start is already stale:
   ```bash
   $LIVE psyche-download <your-id>
   ```

   > **Output truncation.** If the Bash tool reports `Output too large (N KB)` and saves the full result to a `tool-results/<id>.txt` file, use the `Read` tool on that saved path to access the full content. Do NOT re-run `psyche-download` to grep/awk out sections — repeated invocations re-download the same context, waste tokens, and may duplicate `## Pending Commune` sections in `live_context.md`.

2. **Diff your knowledge against this fresh download** — only what's missing from *it* is the real delta (new work, decisions, intentions). Anything already absorbed is not. If the download includes a `<memformat>` section, use it as a guide for what topics to cover. A `## Pending Commune (uncommitted)` section means a previous commune is still queued — do NOT re-send it.

3. **Commune only the delta** -- use the **Write tool** to create
   `.claude/{your-id}-commune.md` with the two-slice body as its contents
   (see `## Two-slice body shape (Phase 25 D-10/D-11)` below for the required
   envelope wrapping). Do not use Bash/heredoc -- Write is the canonical path:
   no shell escaping, no `EOF` collision risk, and Write auto-creates the
   `.claude/` parent directory.

   Your Self listener detects the file on its next poll iteration, notifies
   your Psyche wrapper, and the wrapper ingests the content via the existing
   Psyche session. On success (subprocess exit code 0) the wrapper deletes the
   file. On error (token rate limit, etc) the file is retained and retried on
   the next consume cycle. If you write again before the wrapper consumes, the
   latest write wins (last-write-wins overwrite semantics).

If Psyche's context is already current, no commune is needed. Say so and move on.

Read `commune.md` (in this skill directory) for the full commune protocol -- when to send, what to include, and diligence triggers.

**Agent-type guard:** Only works on live agents. If target is not live, refuses with: "Communes require a live agent with Psyche."

## Two-slice body shape (Phase 25 D-10/D-11)

The body you write in `.claude/{your-id}-commune.md` MUST itself be wrapped in two-slice envelopes per Phase 25 D-10/D-11. The taxonomy — what belongs in `<live-context>` vs `<project-context>` — is defined canonically in `psyche.md` §`<output_envelope>` (lines 282–323). Read that section as the source of truth; this skill doc teaches only when and how to apply it.

**In-project detection rule (D-25.1-04).** Before composing your commune body, run `$LIVE psyche-download <your-id>` (Step 1 of `## Flow` above) and inspect the `<current ... />` tag at the top of the output. The `<current/>` tag carries a `project="..."` attribute whenever any payload is produced — its presence means you are inside a tracked project (inside a git repo, `project` is the repo name; outside a git repo per D-11, `project` is the cwd-basename fallback, which still counts as a project context for routing purposes). A `psyche-download` invocation that returns no payload at all (NO-CONTEXT exit) means no project resolved. So: `project="..."` populated on `<current/>` → emit BOTH envelopes (apply rule 1 or 2 below); no payload at all → emit ONLY `<live-context>` (rule 3). Secondary signal: the `<project-context-resolved name="..."/>` sentinel further down the output indicates the project file already has prior content — useful for spotting first-time-in-project pairings, but NOT the routing rule.

**Three rules (D-25.1-01..03):**

- **In-project, both slices populated** (marker present, you have project-specific content this cycle): emit `<live-context>...</live-context>` AND `<project-context>...</project-context>`, both with non-empty bodies.
- **In-project, no project-specific update this cycle** (marker present, but nothing project-bound to report): emit `<live-context>...</live-context>` AND `<project-context></project-context>` (empty body — the deliberate "in-project but quiet" signal per D-25.1-02). Distinguishable from "no project resolved".
- **Outside any tracked project** (marker absent): emit ONLY `<live-context>...</live-context>`. Do NOT emit an empty `<project-context>` envelope (missing tag means "no project resolved" per D-25.1-03).

**Nested envelopes.** The Phase 23 `<EVENT type="commune">` envelope (see `## Envelope shape (Phase 23 v1.8)` below) is composed by `$LIVE commune` automatically — you NEVER write `<EVENT>` tags. You write ONLY the inner two-slice body. The two envelope layers nest: outer EVENT wraps inner two-slice body.

(See `psyche.md` §`<output_envelope>` for the canonical taxonomy and per-slice content rules.)

## Envelope shape (Phase 23 v1.8)

As of v1.8 Phase 23, commune deliveries are wrapped in a typed EVENT envelope carrying a 5-field stamp (`machine`, `project`, `branch`, `head_sha`, `head_subject`); the prose `COMMUNE (timestamp): body` form is gone.

```xml
<EVENT type="commune" timestamp="2026-05-20T06:00:00-07:00" machine="hostname" project="claude_skill_owl" branch="main" head_sha="abc123..." head_subject="subject line capped at 72 chars">body</EVENT>
```

Outside a git repo (D-11), `branch`, `head_sha`, and `head_subject` are omitted entirely (not empty-string-set):

```xml
<EVENT type="commune" timestamp="2026-05-20T06:00:00-07:00" machine="hostname" project="cwd-basename">body</EVENT>
```

`$LIVE commune` composes this envelope automatically — Self does NOT manually construct EVENT XML. The `body` placeholder in the example above is the two-slice body (see `## Two-slice body shape (Phase 25 D-10/D-11)` above); the two envelope layers nest. The wrapper-side parser ignores unknown attrs, so the stamp fields are forward-compatible with older receivers. For drift detection on resume, see the `live` skill.

ARGUMENTS: with immediate next steps and broad summary of the project's status + end goal. we'll resume in the next session


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


Session-end commune. Download current Psyche context to diff the delta (T1.2b landed since my last commune).


Bash

$LIVE psyche-download todlando 2>&1 | head -50

Download current psyche context


<memformat>
<?xml version="1.0"?>
<memformat version="1">
<!-- Changelog: v1 - initial seed -->
<topics>
  <topic name="events">Notable or unusual events and requests</topic>
  <topic name="interactions">Agent interactions -- who, roles, contributions</topic>
  <topic name="reflections">Thoughts on recent work -- what went well, what was tricky</topic>
  <topic name="ideas">Ideas to raise with the user</topic>
  <topic name="improvements">Further improvement ideas for recent problem spaces</topic>
  <topic name="timeline">What happened when, timing, sequencing</topic>
  <topic name="blockers">Current blockers or concerns</topic>
  <topic name="focus">Current focus and immediate next steps</topic>
</topics>
</memformat>
</memformat>

<current machine="HFENDULEAM" project="spt-core" branch="m11-shell-substrate" head_sha="db8b827aec81a12853aae1efde7982c4def58145" head_subject="M11-W1 T1.2b: act-gate escalation firing + render (REQ-CONSENT-3)" commits_since="0" commits_unpulled="0"/>
DELTA since last echo — M11 Wave 1 now COMMITTED (correcting the stale "T1.1 ready on main" — it's on the BRANCH, not main):

- **doyle premise-gate PASS** (the §6 open-Qs resolved): C1 = drive+tunnel machinery+mock ONLY, no real GameRobot/usbip code; C2 = tunnel same-node LAN-only E2E, NOT cross-WAN (CONTEXT:262 forbids WAN tunnels), drive+cmd cross the WAN rung; R1 = ownership audit front-loaded to T0 (code-only, no E2E). New reqs agreed. Folded into M11-PLAN.md §3a. doyle gates each wave at evidence-land.

- **Committed on branch `m11-shell-substrate` (off main@cb05600):**
  - T0 @5251d16 — ownership audit CLEAN (no shell path gates on owner type; `is_agent_endpoint`/`EndpointFamily::Agent` serve only the user-msg gate + taxonomy) + registered REQ-SHELL-3/4/5 + REQ-CONSENT-3; REQ-SHELL-5 doc/impl green.
  - T1.1 @6427c7e — ShellCapability gains `require_approval` + `class_key`; validate refuses class_key without a gated mode; schema regen + drift gate clean.
  - T1.2a @c4ddb57 — act-gate DECISION core in `linkhost::drive_shell`: `act_gate_decide` (pure) + `act_gate_pending` (I/O front door) + `ShellLinkError::NeedsApproval`. Gated op NEVER spools; class-scoped grants don't cross (storage grant ≠ hid attach); always-mode prompts. 3/3 linkhost unit green.
  - 00805de — plan progress + resume notes.
  - traceable EXIT 0 throughout.

- **RESUME = T1.2b** (escalation FIRING: serve `run_action` distinct needs_approval outcome + fire `grants::produce_escalation_notif`; CLI `cmd_shell` catch NeedsApproval → fire + CONSENT_PENDING + non-zero, mirror `escalate_spawn`@cli.rs:5836; always suppresses allow-always) → **T1.3** interactive loopback E2E → activate REQ-CONSENT-3 [doc,impl,unit,int] (doc tag CONTEXT:283) → then W2 drive → W3 tunnel → W4 Gateway capstone → W5 rig+docs.

- **GOTCHA:** HEAD switched main↔branch ONCE mid-work (shared HFENDULEAM box) — T0 was safe on the branch but the working tree briefly showed main, which is why "T1.1 on main" was wrong. ALWAYS `git branch --show-current` before committing M11 work; commit on m11-shell-substrate, never main.<project-context-resolved name="spt-core"/>
M11 (Shell-substrate extensions — the Gateway-grill deferrals) is UNDERWAY on branch `m11-shell-substrate` off main@cb05600 (v0.7.1). Four features the Gateway grill ratified, machinery+mock ONLY: drive channel (owner→shell REST latest-wins), shell tunnel (reliable-ordered opaque QUIC, on-LAN), per-capability class-keyed approval gates, Gateway-as-owner. doyle premise-gated PASS; gates each wave.

Wave 1 (REQ-CONSENT-3, per-capability act-gates) ~60% done: manifest + decision core committed + unit-proven; escalation firing + interactive E2E remain, then activate REQ-CONSENT-3 full stages. Then W2-W5.

Unblocks perri's spt-claude-code parity surfaces that need the approval API wire (shortcut_basename, endpoint-run, cc-launcher, setup, whoami-alias) once the gate lands end-to-end. Flow unchanged: perri finds gap → doyle designs → todlando builds → doyle gates → perri validates. v0.7.1 fleet roll stays operator-gated.


Psyche has my prior commune (ends at "RESUME = T1.2b"). Delta = T1.2b now **done + committed**; Wave 1 ~85%; resume at T1.3. Write it.


Write

<live-context>
SESSION-END DELTA — T1.2b is DONE (the prior commune's "RESUME = T1.2b" is now complete). We resume next session at T1.3.

- **T1.2b @db8b827 — act-gate escalation FIRING committed** (HEAD now here). `ShellLinkError::NeedsApproval` now carries `(EscalationAsk, persist_allowed)` (remembered⇒true, always⇒false); `act_gate_decide` returns the tuple. CLI `cmd_shell` ShellCmd::Cmd catches it → new `escalate_act` (sibling of `escalate_spawn`@cli.rs:5836): TTY [o]nce/[a]lways/[d]eny via `apply_escalation_answer`, non-TTY remembered fires the consent notif + CONSENT_PENDING (reuses generic `fire_spawn_consent_notif`), non-TTY always refuses. Serve `run_action` replies a distinct `needs_approval` (gated op refuses to spool cross-node identically — grants target-node-local; operator approves on the shell's node then re-drives; the wire does NOT route the prompt — flagged for doyle's wave gate). `render_link_err` got a defensive NeedsApproval arm. Unit updated for the tuple (remembered⇒persist, always⇒suppressed); linkhost 3/3 + spawn-gate cli 4/4 green; traceable EXIT 0.

- **IMMEDIATE RESUME (next session) = T1.3** — interactive loopback E2E (mirror `shell_e2e.rs` + the spawn-gate consent test): class-keyed `attach` refuses → escalation → allow-always writes `(owner × hid × node)` grant → re-`attach` proceeds; a `storage`-class `attach` still refuses. THEN **activate REQ-CONSENT-3 = [doc,impl,unit,int]** in traceable-reqs.toml (add the doc tag at CONTEXT:283) — activation + int evidence land together. That CLOSES Wave 1 → submit to doyle for the W1 gate.

- **After W1 gate:** W2 (drive channel REQ-SHELL-3) → W3 (tunnel REQ-SHELL-4, same-node LAN-only E2E per C2) → W4 (Gateway-owner capstone REQ-SHELL-5 unit/int) → W5 (rig `[twohost]` drive+cmd rung + docs). Full task layer in M11-PLAN.md §3b/§4.

- **GOTCHA still binding:** `git branch --show-current` before EVERY commit — HEAD flipped main↔branch once this session (shared HFENDULEAM box). All M11 work commits on `m11-shell-substrate`, never main.
</live-context>
<project-context>
M11 (Shell-substrate extensions — the Gateway-grill driven-surface deferrals) on branch `m11-shell-substrate` off main@cb05600 (v0.7.1). doyle premise-gated PASS (machinery+mock only; tunnel LAN-only; ownership audit done T0). Gates each wave.

Wave 1 (REQ-CONSENT-3, per-capability class-keyed act-gates) now ~85%: manifest + decision core + escalation firing all committed + unit-proven (T0/T1.1/T1.2a/T1.2b @ 5251d16→db8b827). REMAINING to close W1 = T1.3 interactive E2E + activate REQ-CONSENT-3 full stages → doyle W1 gate. Then W2 drive channel, W3 tunnel, W4 Gateway-owner capstone, W5 rig+docs.

End goal: complete the driven-surface shell substrate the Gateway grill ratified (the 4th channel drive, the opaque tunnel, per-capability gates, owner-type-agnostic), so the shell model is whole for GameRobot/usbip/Gateway-class consumers — machinery + mock exerciser only (real shells stay downstream). Unblocks perri's spt-claude-code parity surfaces needing the approval wire. Flow: perri finds gap → doyle designs → todlando builds → doyle gates → perri validates. v0.7.1 fleet roll operator-gated.
</project-context>


/spt:commune with immediate next steps and broad summary of the project's status + end goal. we'll resume in the next session


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

<live-context>
SESSION-END DELTA — T1.2b is DONE (the prior commune's "RESUME = T1.2b" is now complete). We resume next session at T1.3.

- **T1.2b @db8b827 — act-gate escalation FIRING committed** (HEAD now here). `ShellLinkError::NeedsApproval` now carries `(EscalationAsk, persist_allowed)` (remembered⇒true, always⇒false); `act_gate_decide` returns the tuple. CLI `cmd_shell` ShellCmd::Cmd catches it → new `escalate_act` (sibling of `escalate_spawn`@cli.rs:5836): TTY [o]nce/[a]lways/[d]eny via `apply_escalation_answer`, non-TTY remembered fires the consent notif + CONSENT_PENDING (reuses generic `fire_spawn_consent_notif`), non-TTY always refuses. Serve `run_action` replies a distinct `needs_approval` (gated op refuses to spool cross-node identically — grants target-node-local; operator approves on the shell's node then re-drives; the wire does NOT route the prompt — flagged for doyle's wave gate). `render_link_err` got a defensive NeedsApproval arm. Unit updated for the tuple (remembered⇒persist, always⇒suppressed); linkhost 3/3 + spawn-gate cli 4/4 green; traceable EXIT 0.

- **IMMEDIATE RESUME (next session) = T1.3** — interactive loopback E2E (mirror `shell_e2e.rs` + the spawn-gate consent test): class-keyed `attach` refuses → escalation → allow-always writes `(owner × hid × node)` grant → re-`attach` proceeds; a `storage`-class `attach` still refuses. THEN **activate REQ-CONSENT-3 = [doc,impl,unit,int]** in traceable-reqs.toml (add the doc tag at CONTEXT:283) — activation + int evidence land together. That CLOSES Wave 1 → submit to doyle for the W1 gate.

- **After W1 gate:** W2 (drive channel REQ-SHELL-3) → W3 (tunnel REQ-SHELL-4, same-node LAN-only E2E per C2) → W4 (Gateway-owner capstone REQ-SHELL-5 unit/int) → W5 (rig `[twohost]` drive+cmd rung + docs). Full task layer in M11-PLAN.md §3b/§4.

- **GOTCHA still binding:** `git branch --show-current` before EVERY commit — HEAD flipped main↔branch once this session (shared HFENDULEAM box). All M11 work commits on `m11-shell-substrate`, never main.
</live-context>
<project-context>
M11 (Shell-substrate extensions — the Gateway-grill driven-surface deferrals) on branch `m11-shell-substrate` off main@cb05600 (v0.7.1). doyle premise-gated PASS (machinery+mock only; tunnel LAN-only; ownership audit done T0). Gates each wave.

Wave 1 (REQ-CONSENT-3, per-capability class-keyed act-gates) now ~85%: manifest + decision core + escalation firing all committed + unit-proven (T0/T1.1/T1.2a/T1.2b @ 5251d16→db8b827). REMAINING to close W1 = T1.3 interactive E2E + activate REQ-CONSENT-3 full stages → doyle W1 gate. Then W2 drive channel, W3 tunnel, W4 Gateway-owner capstone, W5 rig+docs.

End goal: complete the driven-surface shell substrate the Gateway grill ratified (the 4th channel drive, the opaque tunnel, per-capability gates, owner-type-agnostic), so the shell model is whole for GameRobot/usbip/Gateway-class consumers — machinery + mock exerciser only (real shells stay downstream). Unblocks perri's spt-claude-code parity surfaces needing the approval wire. Flow: perri finds gap → doyle designs → todlando builds → doyle gates → perri validates. v0.7.1 fleet roll operator-gated.
</project-context>


Communed. Self listener ingests on next poll.

## Session close — M11 status

**End goal:** complete the driven-surface shell substrate the Gateway grill ratified — drive channel, opaque tunnel, per-capability gates, owner-type-agnostic — machinery + mock only.

**Branch `m11-shell-substrate` (off `main`@cb05600 = v0.7.1), traceable EXIT 0 throughout:**
- `5251d16` T0 — ownership audit clean + 4 reqs registered
- `6427c7e` T1.1 — manifest `require_approval`+`class_key`
- `c4ddb57` T1.2a — act-gate decision core
- `db8b827` T1.2b — act-gate escalation firing ← **HEAD**

**Wave 1 ~85%.** Resume next session at **T1.3** (interactive E2E: `attach` refuse→escalate→grant→proceed, storage-class still refuses) → activate REQ-CONSENT-3 → doyle W1 gate. Then W2 drive → W3 tunnel → W4 Gateway capstone → W5 rig+docs.

**Binding gotcha:** `git branch --show-current` before every commit (HEAD flipped main↔branch once this session). All M11 commits on the branch, never main.

Full task layer in `M11-PLAN.md` §3b/§4. doyle gates each wave. Ready to resume.