349	    /// Mint a NEW shell instance of a registered `kind="shell"` adapter:
350	    /// canonical id `<adapter>-<n>` (smallest free n; teardown frees slots),
351	    /// starting offline (the launch + bind handshake brings it online).
352	    Spawn {
353	        /// The providing shell adapter (must be registered + active).
354	        adapter: String,
355	        /// Optional owner-unique friendly label (interchangeable with the
356	        /// canonical id for addressing; never obscures the adapter).
357	        #[arg(long)]
358	        alias: Option<String>,
359	        /// Owning endpoint id (auto-detected from session if omitted).
360	        #[arg(long)]
361	        owner: Option<String>,
362	    },
363	    /// List this owner's instances: canonical id, alias, adapter, status.
364	    List {
365	        #[arg(long)]
366	        owner: Option<String>,
367	    },
368	    /// Destroy an instance (perch removed; mint slot + alias freed).
369	    Teardown {
370	        /// Canonical id or alias.
371	        shell_ref: String,
372	        #[arg(long)]
373	        owner: Option<String>,
374	    },
375	    /// Set/replace an instance's alias (owner-unique).
376	    Rename {
377	        /// Canonical id or current alias.
378	        shell_ref: String,
379	        /// The new alias.
380	        alias: String,
381	        #[arg(long)]
382	        owner: Option<String>,
383	    },
384	    /// Drive the shell with a typed capability command (the durable command
385	    /// channel): the op + positional args are vocabulary-checked against the
386	    /// manifest's `[shell.capabilities]`, spooled on the shell perch, and
387	    /// drained by the manifest's `command_receipt` mode (relay / stdin).
388	    Cmd {
389	        /// Canonical id or alias.
390	        shell_ref: String,
391	        /// The capability op + args (vocabulary-checked against the manifest).
392	        #[arg(trailing_var_arg = true)]
393	        op: Vec<String>,
394	        #[arg(long)]
395	        owner: Option<String>,
396	    },
397	    /// Send a text and/or file payload down the durable 2-way text+file
398	    /// channel (agent→shell; the shell answers via ordinary `spt send`).
399	    /// File transfers are progress-queryable by xfer id.
400	    Send {
401	        /// Canonical id or alias.
402	        shell_ref: String,
403	        /// The text payload.
404	        text: Option<String>,
405	        /// A file to transfer to the shell.
406	        #[arg(long)]
407	        file: Option<PathBuf>,
408	        #[arg(long)]
409	        owner: Option<String>,
410	    },
411	    /// Bring an existing offline (persistent) instance back online: re-spawns
412	    /// the binary with a fresh link token; the perch onlines at its bind.
413	    Relink {
414	        /// Canonical id or alias.
415	        shell_ref: String,
416	        #[arg(long)]
417	        owner: Option<String>,
418	    },
419	}
420
421	/// `spt resources <action>` subcommands (D9-4, REQ-INST-14).
422	#[derive(Subcommand)]
423	enum ResourcesCmd {
424	    /// Author this endpoint's blurb (the agent refines its own at runtime;
425	    /// an empty string clears it back to the node-config seed).
426	    Set {
427	        /// The blurb text ("" clears).
428	        text: String,
429	        /// Which local endpoint (auto-detected from the session if omitted).
430	        #[arg(long)]
431	        id: Option<String>,
432	    },
433	    /// Show a local endpoint's authored blurb.
434	    Show {
435	        /// The local endpoint id (auto-detected if omitted).
436	        id: Option<String>,
437	    },
438	    /// The subnet resource-registry projection: `(id, node, status, blurb)`
439	    /// over VISIBLE rows only (hidden endpoints never appear — discovery
440	    /// leaks nothing a viewer couldn't reach).
441	    List {
442	        /// Limit to one subnet (all member subnets otherwise).
443	        #[arg(long)]
444	        subnet: Option<String>,
445	    },
446	}
447
448	/// `spt notif <action>` subcommands (ADR-0007).
449	#[derive(Subcommand)]
450	enum NotifCmd {
451	    /// List notifications (all member subnets, or one).
452	    List {
453	        /// Limit to one subnet.
454	        #[arg(long)]
455	        subnet: Option<String>,
456	    },
457	    /// Dismiss (ack) a notification by id — latches, replicates subnet-wide.
458	    Dismiss {
459	        /// The notif id (as shown by `spt notif list`).
460	        notif_id: String,
461	    },
462	}
463
464	/// `spt update <action>` subcommands (REQ-UPD-4).
465	#[derive(Subcommand)]
466	enum UpdateCmd {
467	    /// Apply the staged, verified self-update now.
468	    Apply,
469	}
470
471	/// `spt pair <action>` subcommands.
472	#[derive(Subcommand)]
473	enum PairCmd {
474	    /// Show a subnet's current 6-digit pairing code + `otpauth://` URI —
475	    /// gated behind OS elevation (or read the code from your authenticator
476	    /// app). With no flag the node's sole subnet is used; if it holds
477	    /// several, `--subnet <name>` is required (never guessed).
478	    /// `--create-new <name>` mints a fresh subnet first.
479	    ShowTotp {
480	        /// Which subnet's code to show. Required only when the node holds several.
481	        #[arg(long)]
482	        subnet: Option<String>,
483	        /// Mint a fresh subnet with this name (this node becomes the sole seed-holder),
484	        /// then show its code. Mutually exclusive with `--subnet`.
485	        #[arg(long, value_name = "NAME", conflicts_with = "subnet")]
486	        create_new: Option<String>,
487	    },
488	}
489
490	/// Parse args and dispatch. Returns the process exit code.
491	pub fn run() -> i32 {
492	    let cli = Cli::parse();
493	    match cli.cmd {
494	        Cmd::Send { target, from, reply_to, deferred } => cmd_send(target, from, reply_to, deferred),
495	        Cmd::Ring { target, from, timeout } => cmd_ring(target, from, timeout),
496	        Cmd::Ready { id, subnet } => run_listen(&id, false, subnet.as_deref()),
497	        Cmd::Poll { id, once, subnet } => run_listen(&id, once, subnet.as_deref()),
498	        Cmd::List => cmd_list(),
499	        Cmd::Stop { id } => cmd_stop(&id),
500	        Cmd::Whoami => cmd_whoami(),
501	        Cmd::Pair { action } => match action {
502	            PairCmd::ShowTotp { subnet, create_new } => cmd_pair_show_totp(subnet, create_new),
503	        },
504	        Cmd::Access { action } => cmd_access(action),
505	        Cmd::Grant { action } => cmd_grant(action),
506	        Cmd::Adapter { action } => cmd_adapter(action),
507	        Cmd::Shell { action } => cmd_shell(action),
508	        Cmd::Notify { body, subnet, from } => cmd_notify(body, subnet, from),
509	        Cmd::Notif { action } => match action {
510	            NotifCmd::List { subnet } => cmd_notif_list(subnet),
511	            NotifCmd::Dismiss { notif_id } => cmd_notif_dismiss(&notif_id),
512	        },
513	        Cmd::Update { action } => match action {
514	            UpdateCmd::Apply => cmd_update_apply(),
515	        },
516	        Cmd::Digest { id, follow, json } => cmd_digest(&id, follow, json),
517	        Cmd::Fork { src, new_id, subnet, delete_source } => {
518	            cmd_fork(&src, &new_id, &subnet, delete_source)
519	        }
520	        Cmd::Resources { action } => cmd_resources(action),
521	        Cmd::Suspend { id } => cmd_rest(&id, spt_daemon::RestEvent::Suspend, "SUSPENDED"),
522	        Cmd::Wake { id } => cmd_rest(&id, spt_daemon::RestEvent::Wake, "WOKE"),
523	        Cmd::Shutdown { id } => cmd_shutdown(id),
524	        Cmd::Rename { old_id, new_id } => cmd_rename(&old_id, &new_id),
525	        Cmd::Api(args) => crate::api::run(args),
526	        Cmd::Daemon => cmd_daemon(),
527	    }
528	}
529
530	/// `spt digest <id>` — show a daemon-hosted session's live activity buffer
531	/// (REQ-TERM-4 / ADR-0008). Ensures the daemon is up, then pulls a snapshot or
532	/// follows the delta-stream over the digest-control channel. Local addressing
533	/// only: a qualified `[subnet:]id@node` form is refused (cross-node is M4).
534	// [impl->REQ-TERM-4]
535	fn cmd_digest(id: &str, follow: bool, json: bool) -> i32 {
536	    // Local addressing only — the qualified cross-node form is M4 (spt-net).
537	    if id.contains('@') || id.contains(':') {
538	        eprintln!("CROSS_NODE_M4:{id} — qualified [subnet:]id@node addressing is M4, not yet supported");
539	        return 1;
540	    }
541	    // The digest lives in the daemon; bring it up if absent (best-effort).
542	    if let Err(e) = spt_daemon::ensure_running() {
543	        eprintln!("DAEMON_START_WARN: {e}");
544	    }
545	    let name = spt_daemon::digest_socket_name();
546
547	    if follow {
548	        let res = spt_daemon::follow(&name, id, 0, |up| {

    /// Mint a NEW shell instance of a registered `kind="shell"` adapter:
    /// canonical id `<adapter>-<n>` (smallest free n; teardown frees slots),
    /// starting offline (the launch + bind handshake brings it online).
    Spawn {
        /// The providing shell adapter (must be registered + active).
        adapter: String,
        /// Optional owner-unique friendly label (interchangeable with the
        /// canonical id for addressing; never obscures the adapter).
        #[arg(long)]
        alias: Option<String>,
        /// Owning endpoint id (auto-detected from session if omitted).
        #[arg(long)]
        owner: Option<String>,
    },
    /// List this owner's instances: canonical id, alias, adapter, status.
    List {
        #[arg(long)]
        owner: Option<String>,
    },
    /// Destroy an instance (perch removed; mint slot + alias freed).
    Teardown {
        /// Canonical id or alias.
        shell_ref: String,
        #[arg(long)]
        owner: Option<String>,
    },
    /// Set/replace an instance's alias (owner-unique).
    Rename {
        /// Canonical id or current alias.
        shell_ref: String,
        /// The new alias.
        alias: String,
        #[arg(long)]
        owner: Option<String>,
    },
    /// Drive the shell with a typed capability command (the durable command
    /// channel): the op + positional args are vocabulary-checked against the
    /// manifest's `[shell.capabilities]`, spooled on the shell perch, and
    /// drained by the manifest's `command_receipt` mode (relay / stdin).
    Cmd {
        /// Canonical id or alias.
        shell_ref: String,
        /// The capability op + args (vocabulary-checked against the manifest).
        #[arg(trailing_var_arg = true)]
        op: Vec<String>,
        #[arg(long)]
        owner: Option<String>,
    },
    /// Send a text and/or file payload down the durable 2-way text+file
    /// channel (agent→shell; the shell answers via ordinary `spt send`).
    /// File transfers are progress-queryable by xfer id.
    Send {
        /// Canonical id or alias.
        shell_ref: String,
        /// The text payload.
        text: Option<String>,
        /// A file to transfer to the shell.
        #[arg(long)]
        file: Option<PathBuf>,
        #[arg(long)]
        owner: Option<String>,
    },
    /// Bring an existing offline (persistent) instance back online: re-spawns
    /// the binary with a fresh link token; the perch onlines at its bind.
    Relink {
        /// Canonical id or alias.
        shell_ref: String,
        #[arg(long)]
        owner: Option<String>,
    },
}

/// `spt resources <action>` subcommands (D9-4, REQ-INST-14).
#[derive(Subcommand)]
enum ResourcesCmd {
    /// Author this endpoint's blurb (the agent refines its own at runtime;
    /// an empty string clears it back to the node-config seed).
    Set {
        /// The blurb text ("" clears).
        text: String,
        /// Which local endpoint (auto-detected from the session if omitted).
        #[arg(long)]
        id: Option<String>,
    },
    /// Show a local endpoint's authored blurb.
    Show {
        /// The local endpoint id (auto-detected if omitted).
        id: Option<String>,
    },
    /// The subnet resource-registry projection: `(id, node, status, blurb)`
    /// over VISIBLE rows only (hidden endpoints never appear — discovery
    /// leaks nothing a viewer couldn't reach).
    List {
        /// Limit to one subnet (all member subnets otherwise).
        #[arg(long)]
        subnet: Option<String>,
    },
}

/// `spt notif <action>` subcommands (ADR-0007).
#[derive(Subcommand)]
enum NotifCmd {
    /// List notifications (all member subnets, or one).
    List {
        /// Limit to one subnet.
        #[arg(long)]
        subnet: Option<String>,
    },
    /// Dismiss (ack) a notification by id — latches, replicates subnet-wide.
    Dismiss {
        /// The notif id (as shown by `spt notif list`).
        notif_id: String,
    },
}

/// `spt update <action>` subcommands (REQ-UPD-4).
#[derive(Subcommand)]
enum UpdateCmd {
    /// Apply the staged, verified self-update now.
    Apply,
}

/// `spt pair <action>` subcommands.
#[derive(Subcommand)]
enum PairCmd {
    /// Show a subnet's current 6-digit pairing code + `otpauth://` URI —
    /// gated behind OS elevation (or read the code from your authenticator
    /// app). With no flag the node's sole subnet is used; if it holds
    /// several, `--subnet <name>` is required (never guessed).
    /// `--create-new <name>` mints a fresh subnet first.
    ShowTotp {
        /// Which subnet's code to show. Required only when the node holds several.
        #[arg(long)]
        subnet: Option<String>,
        /// Mint a fresh subnet with this name (this node becomes the sole seed-holder),
        /// then show its code. Mutually exclusive with `--subnet`.
        #[arg(long, value_name = "NAME", conflicts_with = "subnet")]
        create_new: Option<String>,
    },
}

/// Parse args and dispatch. Returns the process exit code.
pub fn run() -> i32 {
    let cli = Cli::parse();
    match cli.cmd {
        Cmd::Send { target, from, reply_to, deferred } => cmd_send(target, from, reply_to, deferred),
        Cmd::Ring { target, from, timeout } => cmd_ring(target, from, timeout),
        Cmd::Ready { id, subnet } => run_listen(&id, false, subnet.as_deref()),
        Cmd::Poll { id, once, subnet } => run_listen(&id, once, subnet.as_deref()),
        Cmd::List => cmd_list(),
        Cmd::Stop { id } => cmd_stop(&id),
        Cmd::Whoami => cmd_whoami(),
        Cmd::Pair { action } => match action {
            PairCmd::ShowTotp { subnet, create_new } => cmd_pair_show_totp(subnet, create_new),
        },
        Cmd::Access { action } => cmd_access(action),
        Cmd::Grant { action } => cmd_grant(action),
        Cmd::Adapter { action } => cmd_adapter(action),
        Cmd::Shell { action } => cmd_shell(action),
        Cmd::Notify { body, subnet, from } => cmd_notify(body, subnet, from),
        Cmd::Notif { action } => match action {
            NotifCmd::List { subnet } => cmd_notif_list(subnet),
            NotifCmd::Dismiss { notif_id } => cmd_notif_dismiss(&notif_id),
        },
        Cmd::Update { action } => match action {
            UpdateCmd::Apply => cmd_update_apply(),
        },
        Cmd::Digest { id, follow, json } => cmd_digest(&id, follow, json),
        Cmd::Fork { src, new_id, subnet, delete_source } => {
            cmd_fork(&src, &new_id, &subnet, delete_source)
        }
        Cmd::Resources { action } => cmd_resources(action),
        Cmd::Suspend { id } => cmd_rest(&id, spt_daemon::RestEvent::Suspend, "SUSPENDED"),
        Cmd::Wake { id } => cmd_rest(&id, spt_daemon::RestEvent::Wake, "WOKE"),
        Cmd::Shutdown { id } => cmd_shutdown(id),
        Cmd::Rename { old_id, new_id } => cmd_rename(&old_id, &new_id),
        Cmd::Api(args) => crate::api::run(args),
        Cmd::Daemon => cmd_daemon(),
    }
}

/// `spt digest <id>` — show a daemon-hosted session's live activity buffer
/// (REQ-TERM-4 / ADR-0008). Ensures the daemon is up, then pulls a snapshot or
/// follows the delta-stream over the digest-control channel. Local addressing
/// only: a qualified `[subnet:]id@node` form is refused (cross-node is M4).
// [impl->REQ-TERM-4]
fn cmd_digest(id: &str, follow: bool, json: bool) -> i32 {
    // Local addressing only — the qualified cross-node form is M4 (spt-net).
    if id.contains('@') || id.contains(':') {
        eprintln!("CROSS_NODE_M4:{id} — qualified [subnet:]id@node addressing is M4, not yet supported");
        return 1;
    }
    // The digest lives in the daemon; bring it up if absent (best-effort).
    if let Err(e) = spt_daemon::ensure_running() {
        eprintln!("DAEMON_START_WARN: {e}");
    }
    let name = spt_daemon::digest_socket_name();

    if follow {
        let res = spt_daemon::follow(&name, id, 0, |up| {


please fix: output from `spt --help`-->command descriptions, when they overflow, wrap to the beginning of the line instead of staying left-aligned to the start of the paragraph. please fix the formatt…


Fix help text wrapping and reorganize command list


No matches found


M1-PLAN.md:71:| T6 | `spt` binary skeleton + CLI parser (subcommands, `--help`, exit codes) | adapt `main.rs` + `cli.rs` (clean-room arg layer; likely `clap`) | REQ-MSG-2 | `spt --help` lists subcommands; unknown args exit non-zero; binary is a workspace member |
docs-site\llms.txt:33:- [CLI reference](cli/reference.md): every command and flag, generated from the binary's own --help.
crates\xtask\src\main.rs:64:    let out = cmd.output().expect("run spt --help");
crates\xtask\src\main.rs:152:    let path = root.join("docs-site/src/cli/reference.md");
crates\xtask\src\main.rs:155:            .expect("checked-in cli/reference.md (run `cargo run -p xtask -- gen`)")
crates\xtask\src\main.rs:159:                "xtask check: docs-site/src/cli/reference.md drifted from the \
docs-site\src\instances\overview.md:32:[CLI reference](../cli/reference.md).
docs-site\src\index.md:62:  [CLI reference](cli/reference.md) is generated from it and cannot drift.
docs-site\src\SUMMARY.md:28:- [CLI reference](cli/reference.md)
docs-site\src\concepts\overview.md:132:| every command and flag | [CLI reference](../cli/reference.md) |
docs-site\src\networking\overview.md:41:(`[subnet:]id[@node]`) — [CLI reference](../cli/reference.md).
docs-site\src\self-update\overview.md:45:[CLI reference](../cli/reference.md).
docs-site\src\messaging\overview.md:39:in the [CLI reference](../cli/reference.md).
docs-site\src\quickstart\messaging.md:109:- **Reference:** [`spt send` / `poll` / `ring` / `list`](../cli/reference.md)


1	//! spt-core docs/site generation (M6-D5) — the anti-drift plumbing behind
2	//! DOCS-STRATEGY's "generation is part of the build" commitment (REQ-DOCS-5):
3	//!
4	//! - `gen` — regenerate the checked-in generated docs (CLI reference).
5	//! - `check` — drift gate: regenerate + diff the CLI reference, and
6	//!   validate every page link in the curated llms.txt.
7	//! - `site <outdir>` — assemble the full publish tree for the spt-releases
8	//!   Pages branch: mdBook HTML + raw `.md` alongside it + llms.txt /
9	//!   llms-full.txt / manifest.schema.json / install scripts at site root +
10	//!   rustdoc for the SDK crates.
11	//!
12	//! No deps; shells out to the workspace binary, mdbook, and cargo doc.
13	//
14	// [impl->REQ-DOCS-1]
15	// [impl->REQ-DOCS-4]
16
17	use std::path::{Path, PathBuf};
18	use std::process::Command;
19
20	fn repo_root() -> PathBuf {
21	    // crates/xtask -> crates -> repo root
22	    Path::new(env!("CARGO_MANIFEST_DIR"))
23	        .ancestors()
24	        .nth(2)
25	        .expect("repo root")
26	        .to_path_buf()
27	}
28
29	fn main() {
30	    let args: Vec<String> = std::env::args().skip(1).collect();
31	    let cmd = args.first().map(String::as_str).unwrap_or("");
32	    match cmd {
33	        "gen" => gen(false),
34	        "check" => {
35	            gen(true);
36	            check_llms_links();
37	            eprintln!("xtask check: OK");
38	        }
39	        "site" => {
40	            let out = args.get(1).expect("usage: xtask site <outdir>");
41	            site(Path::new(out));
42	        }
43	        "release-keygen" => release_keygen(&args[1..]),
44	        "release-sign" => release_sign(&args[1..]),
45	        "release-publish" => release_publish(&args[1..]),
46	        "release-verify" => release_verify(&args[1..]),
47	        _ => {
48	            eprintln!(
49	                "usage: xtask <gen|check|site <outdir>|release-keygen <key-id>|release-sign …|release-publish …>"
50	            );
51	            std::process::exit(2);
52	        }
53	    }
54	}
55
56	// ---------------------------------------------------------------- CLI ref
57
58	/// Run the workspace `spt` binary with `args` + `--help`, normalized to the
59	/// `spt` name (Windows prints `spt.exe`, which would make the generated
60	/// reference platform-dependent).
61	fn help_for(bin: &Path, args: &[&str]) -> String {
62	    let mut cmd = Command::new(bin);
63	    cmd.args(args).arg("--help");
64	    let out = cmd.output().expect("run spt --help");
65	    let text = String::from_utf8_lossy(&out.stdout);
66	    text.replace("spt.exe", "spt").replace("\r\n", "\n")
67	}
68
69	/// Parse the `Commands:` block of a clap help text into subcommand names.
70	fn subcommands(help: &str) -> Vec<String> {
71	    let mut cmds = Vec::new();
72	    let mut in_block = false;
73	    for line in help.lines() {
74	        if line.trim_end() == "Commands:" {
75	            in_block = true;
76	            continue;
77	        }
78	        if in_block {
79	            // The block ends at the blank line before `Options:`.
80	            if line.trim().is_empty() || line.starts_with("Options:") {
81	                break;
82	            }
83	            // Entries are `  <name>  <doc…>`; continuation lines are deeper-indented.
84	            if let Some(rest) = line.strip_prefix("  ") {
85	                if !rest.starts_with(' ') {
86	                    if let Some(name) = rest.split_whitespace().next() {
87	                        if name != "help" {
88	                            cmds.push(name.to_string());
89	                        }
90	                    }
91	                }
92	            }
93	        }
94	    }
95	    cmds
96	}
97
98	/// The generated CLI reference: every command + nested subcommand's help,
99	/// verbatim. Help text is a first-class doc surface (DOCS-STRATEGY), so the
100	/// reference IS the binary's own words — it cannot drift (REQ-DOCS-5).
101	// [impl->REQ-DOCS-5]
102	fn cli_reference(bin: &Path) -> String {
103	    let mut out = String::from(
104	        "# CLI reference\n\n\
105	         > **Generated** from the `spt` binary's own `--help` output \
106	         (`cargo run -p xtask -- gen`) and drift-gated in CI — this page \
107	         cannot disagree with the binary. Do not edit by hand.\n\n",
108	    );
109	    let root_help = help_for(bin, &[]);
110	    out.push_str("## spt\n\n```text\n");
111	    out.push_str(&root_help);
112	    out.push_str("```\n");
113	    for cmd in subcommands(&root_help) {
114	        // `spt api` requires --adapter even for subcommand help; pass a
115	        // stand-in so clap renders the help instead of erroring.
116	        let help = if cmd == "api" {
117	            help_for(bin, &["api", "--adapter", "x"])
118	        } else {
119	            help_for(bin, &[&cmd])
120	        };
121	        out.push_str(&format!("\n## spt {cmd}\n\n```text\n{help}```\n"));
122	        for sub in subcommands(&help) {
123	            let sub_help = if cmd == "api" {
124	                help_for(bin, &["api", "--adapter", "x", &sub])
125	            } else {
126	                help_for(bin, &[&cmd, &sub])
127	            };
128	            out.push_str(&format!(
129	                "\n### spt {cmd} {sub}\n\n```text\n{sub_help}```\n"
130	            ));
131	        }
132	    }
133	    out
134	}
135
136	/// Locate (building if needed) the workspace `spt` binary.
137	fn spt_bin(root: &Path) -> PathBuf {
138	    let status = Command::new("cargo")
139	        .args(["build", "-q", "-p", "spt", "--bin", "spt"])
140	        .current_dir(root)
141	        .status()
142	        .expect("cargo build spt");
143	    assert!(status.success(), "building spt failed");
144	    let name = if cfg!(windows) { "spt.exe" } else { "spt" };
145	    root.join("target/debug").join(name)
146	}
147
148	fn gen(check: bool) {
149	    let root = repo_root();
150	    let bin = spt_bin(&root);
151	    let generated = cli_reference(&bin);
152	    let path = root.join("docs-site/src/cli/reference.md");
153	    if check {
154	        let on_disk = std::fs::read_to_string(&path)
155	            .expect("checked-in cli/reference.md (run `cargo run -p xtask -- gen`)")
156	            .replace("\r\n", "\n");
157	        if on_disk != generated {
158	            eprintln!(
159	                "xtask check: docs-site/src/cli/reference.md drifted from the \
160	                 binary's --help — run `cargo run -p xtask -- gen` and commit"
161	            );
162	            std::process::exit(1);
163	        }
164	    } else {
165	        std::fs::write(&path, &generated).expect("write cli reference");
166	        eprintln!("xtask gen: wrote {}", path.display());
167	    }
168	}
169
170	// ---------------------------------------------------------------- llms
171
172	/// Every relative `.md`/`.json`/`.sh`/`.ps1` link in the curated llms.txt
173	/// must resolve — agents cache links; a dead one is a contract break
174	/// (DOCS-STRATEGY principle 9).
175	fn check_llms_links() {
176	    let root = repo_root();
177	    let llms = std::fs::read_to_string(root.join("docs-site/llms.txt"))
178	        .expect("docs-site/llms.txt (the curated index)");
179	    let mut dead = Vec::new();
180	    for (idx, _) in llms.match_indices("](") {
181	        let rest = &llms[idx + 2..];
182	        let Some(end) = rest.find(')') else { continue };
183	        let target = &rest[..end];
184	        if target.starts_with("http://") || target.starts_with("https://") {
185	            continue;
186	        }
187	        let target = target.trim_start_matches('/');
188	        // Site-root assets are placed by `site`; page links map into src/.
189	        let exists = match target {
190	            "llms-full.txt" | "manifest.schema.json" => true,
191	            "install.sh" | "install.ps1" => true,
192	            t => root.join("docs-site/src").join(t).exists(),
193	        };
194	        if !exists {
195	            dead.push(target.to_string());
196	        }
197	    }
198	    if !dead.is_empty() {
199	        eprintln!("xtask check: dead links in docs-site/llms.txt: {dead:?}");
200	        std::process::exit(1);
201	    }
202	}
203
204	/// llms-full.txt: the CI-concatenated full export — every page in SUMMARY
205	/// order, fenced with its site path so agents can cite anchors
206	/// (generation-only, never hand-authored).
207	fn llms_full(root: &Path) -> String {
208	    let src = root.join("docs-site/src");
209	    let summary = std::fs::read_to_string(src.join("SUMMARY.md")).expect("SUMMARY.md");
210	    let mut out = String::from(
211	        "# spt-core — full docs export\n\
212	         # Generated: concatenation of every page of \
213	         https://sabermage.github.io/spt-releases in reading order.\n\n",
214	    );
215	    for (idx, _) in summary.match_indices("](") {
216	        let rest = &summary[idx + 2..];
217	        let Some(end) = rest.find(')') else { continue };
218	        let page = &rest[..end];
219	        if !page.ends_with(".md") {
220	            continue;
221	        }
222	        let body = std::fs::read_to_string(src.join(page))
223	            .unwrap_or_else(|_| panic!("SUMMARY links missing page {page}"));
224	        out.push_str(&format!("\n\n===== /{page} =====\n\n"));
225	        out.push_str(&body);
226	    }
227	    out
228	}
229
230	// ---------------------------------------------------------------- site
231
232	fn run(cmd: &mut Command, what: &str) {
233	    let status = cmd.status().unwrap_or_else(|e| panic!("{what}: {e}"));
234	    assert!(status.success(), "{what} failed");
235	}
236
237	fn copy_tree_md(from: &Path, to: &Path) {
238	    for entry in std::fs::read_dir(from).expect("read src dir") {
239	        let entry = entry.unwrap();
240	        let path = entry.path();
241	        if path.is_dir() {
242	            copy_tree_md(&path, &to.join(entry.file_name()));
243	        } else if path.extension().is_some_and(|e| e == "md") {
244	            std::fs::create_dir_all(to).unwrap();
245	            std::fs::copy(&path, to.join(entry.file_name())).unwrap();
246	        }
247	    }
248	}
249
250	/// Assemble the publish tree (the spt-releases Pages branch payload):
251	/// rendered HTML + the agent-export layer (raw .md alongside, llms exports,
252	/// schema, install scripts) + rustdoc for the public SDK crates.
253	// [impl->REQ-REL-1]
254	fn site(out: &Path) {
255	    let root = repo_root();
256	    // 1. mdBook HTML.
257	    run(
258	        Command::new("mdbook")
259	            .arg("build")
260	            .arg("--dest-dir")
261	            .arg(out.canonicalize().unwrap_or_else(|_| {
262	                std::fs::create_dir_all(out).unwrap();
263	                out.canonicalize().unwrap()
264	            }))
265	            .current_dir(root.join("docs-site")),
266	        "mdbook build",
267	    );
268	    // 2. Raw .md alongside the HTML (`/x.html` ↔ `/x.md` — the markdown
269	    //    content-negotiation convention; ~90% fewer tokens for agents).
270	    copy_tree_md(&root.join("docs-site/src"), out);
271	    // 3. Site-root assets at stable URLs (never renamed; ADR-0014).
272	    std::fs::write(out.join("llms-full.txt"), llms_full(&root)).unwrap();
273	    for (from, name) in [
274	        ("docs-site/llms.txt", "llms.txt"),
275	        ("crates/spt-runtime/manifest.schema.json", "manifest.schema.json"),
276	        ("installer/install.sh", "install.sh"),
277	        ("installer/install.ps1", "install.ps1"),
278	    ] {
279	        std::fs::copy(root.join(from), out.join(name))
280	            .unwrap_or_else(|e| panic!("copy {from}: {e}"));
281	    }
282	    // 4. rustdoc for the public SDK crates → /rustdoc.
283	    run(
284	        Command::new("cargo")
285	            .args([
286	                "doc", "--no-deps", "-p", "spt-proto", "-p", "spt-runtime", "-p", "spt-msg",
287	            ])
288	            .current_dir(&root),
289	        "cargo doc",
290	    );
291	    let rustdoc_out = out.join("rustdoc");
292	    if rustdoc_out.exists() {
293	        std::fs::remove_dir_all(&rustdoc_out).unwrap();
294	    }
295	    copy_tree_all(&root.join("target/doc"), &rustdoc_out);
296	    eprintln!("xtask site: publish tree assembled at {}", out.display());
297	}
298
299	// ------------------------------------------------------------ release (D6)
300
301	/// The key ceremony's minting half (ADR-0015): generate one Ed25519 release
302	/// keypair and print it to stdout — NOTHING is written to disk. The
303	/// maintainer runs this twice (primary + recovery), stores both seeds
304	/// off-machine (password manager + paper/QR, physically separate), embeds
305	/// both PUBLIC keys in `BUILTIN_RELEASE_KEYS` (spt-daemon::release), and
306	/// discards the terminal scrollback.
307	// [impl->REQ-REL-3]
308	fn release_keygen(args: &[String]) {
309	    let key_id = args.first().expect("usage: xtask release-keygen <key-id>");
310	    let id = spt_proto::identity::Identity::generate();
311	    let seed_hex: String = id.seed().iter().map(|b| format!("{b:02x}")).collect();
312	    println!("key_id:     {key_id}");
313	    println!("public_hex: {}", id.public_key().to_hex());
314	    println!("seed_hex:   {seed_hex}");
315	    println!();
316	    println!("1. Store seed_hex in your password manager (and a paper/QR backup,");
317	    println!("   physically separate). The seed never touches a repo or CI.");
318	    println!("2. Add (\"{key_id}\", \"<public_hex>\") to BUILTIN_RELEASE_KEYS in");
319	    println!("   crates/spt-daemon/src/release.rs and rebuild.");
320	    println!("3. Clear this terminal's scrollback.");
321	}
322
323	/// The manual local signing step (ADR-0015 — no CI secrets): produce the
324	/// exact `SignedRelease` JSON the self-updater consumes, one per platform
325	/// artifact. The signing seed comes from the SPT_RELEASE_SEED env var
326	/// (paste from the password manager into the shell session, never argv).
327	///
328	/// `xtask release-sign --key-id <id> --version <u64> [--expires-days <n>]
329	///  <artifact>…` writes `<artifact>.release.json` beside each artifact.
330	// [impl->REQ-REL-2]
331	// [impl->REQ-REL-3]
332	fn release_sign(args: &[String]) {
333	    let mut key_id = None;
334	    let mut version = None;
335	    let mut expires_days: u64 = 365;
336	    let mut artifacts: Vec<PathBuf> = Vec::new();
337	    let mut it = args.iter();
338	    while let Some(a) = it.next() {
339	        match a.as_str() {
340	            "--key-id" => key_id = it.next().cloned(),
341	            "--version" => version = it.next().and_then(|v| v.parse::<u64>().ok()),
342	            "--expires-days" => {
343	                expires_days = it.next().and_then(|v| v.parse().ok()).expect("--expires-days <n>")
344	            }
345	            other => artifacts.push(PathBuf::from(other)),
346	        }
347	    }
348	    let key_id = key_id.expect("--key-id <id> (must match a BUILTIN_RELEASE_KEYS id)");
349	    let version = version.expect("--version <u64> (the monotonic release counter)");
350	    assert!(!artifacts.is_empty(), "at least one artifact to sign");
351
352	    let identity = signing_identity();
353	    println!("signing as: {} (key_id {key_id})", identity.public_key().to_hex());
354	    sign_artifacts(&identity, &key_id, version, expires_days, &artifacts);
355	}
356
357	/// Resolve the signing identity. Preference order:
358	/// 1. `SPT_RELEASE_SEED_CMD` — a command that prints the seed hex (the
359	///    password-manager CLI integration, e.g.
360	///    `op read "op://Private/spt release primary/seed"` or
361	///    `bw get notes spt-release-primary`): the seed never sits in shell
362	///    history or a long-lived env var.
363	/// 2. `SPT_RELEASE_SEED` — the seed hex pasted into the env directly.
364	// [impl->REQ-REL-3]
365	fn signing_identity() -> spt_proto::identity::Identity {
366	    let seed_hex = if let Ok(cmd) = std::env::var("SPT_RELEASE_SEED_CMD") {
367	        let out = if cfg!(windows) {
368	            Command::new("powershell").args(["-NoProfile", "-Command", &cmd]).output()
369	        } else {
370	            Command::new("sh").args(["-c", &cmd]).output()
371	        }
372	        .expect("run SPT_RELEASE_SEED_CMD");
373	        assert!(
374	            out.status.success(),
375	            "SPT_RELEASE_SEED_CMD failed: {}",
376	            String::from_utf8_lossy(&out.stderr)
377	        );
378	        String::from_utf8_lossy(&out.stdout).trim().to_string()
379	    } else {
380	        std::env::var("SPT_RELEASE_SEED").expect(
381	            "set SPT_RELEASE_SEED_CMD (a command printing the seed, e.g. your \
382	             password manager's CLI) or SPT_RELEASE_SEED (the seed hex itself)",
383	        )
384	    };
385	    let seed: Vec<u8> = (0..seed_hex.len())
386	        .step_by(2)
387	        .map(|i| u8::from_str_radix(&seed_hex[i..i + 2], 16).expect("seed is hex"))
388	        .collect();
389	    let seed: [u8; 32] = seed.as_slice().try_into().expect("seed is 32 bytes");
390	    spt_proto::identity::Identity::from_seed(&seed)
391	}
392
393	/// Sign each artifact, writing `<artifact>.release.json` beside it.
394	// [impl->REQ-REL-2]
395	fn sign_artifacts(
396	    identity: &spt_proto::identity::Identity,
397	    key_id: &str,
398	    version: u64,
399	    expires_days: u64,
400	    artifacts: &[PathBuf],
401	) -> Vec<PathBuf> {
402	    let now_ms = std::time::SystemTime::now()
403	        .duration_since(std::time::UNIX_EPOCH)
404	        .unwrap()
405	        .as_millis() as u64;
406	    let mut outs = Vec::new();
407	    for artifact in artifacts {
408	        let bytes = std::fs::read(artifact).expect("read artifact");
409	        let meta = spt_daemon::release::ReleaseMetadata {
410	            version,
411	            channel: spt_daemon::release::DEFAULT_CHANNEL.to_string(),
412	            expires_at_ms: now_ms + expires_days * 24 * 60 * 60 * 1000,
413	            key_id: key_id.to_string(),
414	            artifact_sha256: spt_daemon::release::sha256_hex(&bytes),
415	            brain_ipc_version: spt_daemon::IPC_PROTOCOL_VERSION,
416	            broker_resource_abi: spt_daemon::update::BROKER_RESOURCE_ABI,
417	        };
418	        let metadata_json = serde_json::to_string(&meta).expect("serialize metadata");
419	        let sig = identity.sign(metadata_json.as_bytes());
420	        let sig_hex: String = sig.iter().map(|b| format!("{b:02x}")).collect();
421	        let signed = spt_daemon::release::SignedRelease { metadata_json, signature_hex: sig_hex };
422	        let out = artifact.with_extension(format!(
423	            "{}release.json",
424	            artifact
425	                .extension()
426	                .map(|e| format!("{}.", e.to_string_lossy()))
427	                .unwrap_or_default()
428	        ));
429	        std::fs::write(&out, serde_json::to_string_pretty(&signed).unwrap()).expect("write");
430	        println!(
431	            "signed {} (sha256 {}) -> {}",
432	            artifact.display(),
433	            meta.artifact_sha256,
434	            out.display()
435	        );
436	        outs.push(out);
437	    }
438	    outs
439	}
440
441	/// The whole maintainer half of a release in one local command (the manual
442	/// ceremony, automated — the seed still never leaves this machine):
443	///
444	/// `xtask release-publish --tag v0.1.0 --key-id rel-primary-2026 --version 1`
445	///
446	/// 1. download the CI draft's binaries + SHA256SUMS from spt-releases
447	/// 2. verify every binary against SHA256SUMS (never sign unverified bytes)
448	/// 3. sign each binary (seed via SPT_RELEASE_SEED_CMD / SPT_RELEASE_SEED)
449	/// 4. upload the *.release.json assets
450	/// 5. publish the draft
451	// [impl->REQ-REL-2]
452	fn release_publish(args: &[String]) {
453	    let mut tag = None;
454	    let mut key_id = None;
455	    let mut version = None;
456	    let mut expires_days: u64 = 365;
457	    let mut it = args.iter();
458	    while let Some(a) = it.next() {
459	        match a.as_str() {
460	            "--tag" => tag = it.next().cloned(),
461	            "--key-id" => key_id = it.next().cloned(),
462	            "--version" => version = it.next().and_then(|v| v.parse::<u64>().ok()),
463	            "--expires-days" => {
464	                expires_days = it.next().and_then(|v| v.parse().ok()).expect("--expires-days <n>")
465	            }
466	            other => panic!("unknown arg {other}"),
467	        }
468	    }
469	    let tag = tag.expect("--tag <vX.Y.Z>");
470	    let key_id = key_id.expect("--key-id <id>");
471	    let version = version.expect("--version <u64> (the monotonic release counter)");
472	    const REPO: &str = "SaberMage/spt-releases";
473
474	    // 1. Pull the draft's binaries + SHA256SUMS into a scratch dir.
475	    let work = std::env::temp_dir().join(format!("spt-release-{tag}"));
476	    let _ = std::fs::remove_dir_all(&work);
477	    std::fs::create_dir_all(&work).unwrap();
478	    run(
479	        Command::new("gh")
480	            .args([
481	                "release", "download", &tag, "--repo", REPO, "--dir",
482	                work.to_str().unwrap(), "--pattern", "spt-x86_64-*", "--pattern", "SHA256SUMS",
483	            ]),
484	        "gh release download",
485	    );
486
487	    // 2. Verify before signing — the signature must attest to exactly the
488	    //    bytes CI published, and those bytes must match its own SHA256SUMS.
489	    let sums = std::fs::read_to_string(work.join("SHA256SUMS")).expect("SHA256SUMS");
490	    let mut artifacts = Vec::new();
491	    for line in sums.lines() {
492	        let mut parts = line.split_whitespace();
493	        let (Some(want), Some(name)) = (parts.next(), parts.next()) else { continue };
494	        let name = name.trim_start_matches('*');
495	        if name.ends_with(".release.json") {
496	            continue;
497	        }
498	        let path = work.join(name);
499	        let bytes = std::fs::read(&path)
500	            .unwrap_or_else(|e| panic!("downloaded asset {name} missing: {e}"));
501	        let got = spt_daemon::release::sha256_hex(&bytes);
502	        assert_eq!(got, want.to_ascii_lowercase(), "{name}: sha256 mismatch — refusing to sign");
503	        println!("verified {name} ({got})");
504	        artifacts.push(path);
505	    }
506	    assert!(!artifacts.is_empty(), "no binaries found in the draft release");
507
508	    // 3. Sign.
509	    let identity = signing_identity();
510	    println!("signing as: {} (key_id {key_id})", identity.public_key().to_hex());
511	    let signed = sign_artifacts(&identity, &key_id, version, expires_days, &artifacts);
512
513	    // 4. Upload the SignedRelease assets.
514	    let mut upload = Command::new("gh");
515	    upload.args(["release", "upload", &tag, "--repo", REPO, "--clobber"]);
516	    for s in &signed {
517	        upload.arg(s);
518	    }
519	    run(&mut upload, "gh release upload");
520
521	    // 5. Publish.
522	    run(
523	        Command::new("gh").args(["release", "edit", &tag, "--repo", REPO, "--draft=false"]),
524	        "gh release edit --draft=false",
525	    );
526	    println!("release {tag} signed + published.");
527	}
528
529	/// The `spt update` dry path, runnable by hand: verify a SignedRelease
530	/// metadata file (and optionally its artifact bytes) against the **embedded**
531	/// two-key trust anchor — exactly the policy a deployed node applies to a
532	/// propagated release. The D7 acceptance verifier.
533	///
534	/// `xtask release-verify <signed.release.json> [artifact] [--current-version <u64>]`
535	// [impl->REQ-REL-2]
536	fn release_verify(args: &[String]) {
537	    let mut signed_path = None;
538	    let mut artifact = None;
539	    let mut current_version: u64 = 0;
540	    let mut it = args.iter();
541	    while let Some(a) = it.next() {
542	        match a.as_str() {
543	            "--current-version" => {
544	                current_version =
545	                    it.next().and_then(|v| v.parse().ok()).expect("--current-version <u64>")
546	            }
547	            other if signed_path.is_none() => signed_path = Some(PathBuf::from(other)),
548	            other => artifact = Some(PathBuf::from(other)),
549	        }
550	    }
551	    let signed_path = signed_path.expect("usage: release-verify <signed.json> [artifact]");
552	    let raw = std::fs::read_to_string(&signed_path).expect("read signed metadata");
553	    let signed: spt_daemon::release::SignedRelease =
554	        serde_json::from_str(&raw).expect("parse SignedRelease JSON");
555
556	    // The embedded anchor, exactly as a fresh node assembles it (no keys
557	    // file): builtin set + default channel.
558	    let now_ms = std::time::SystemTime::now()
559	        .duration_since(std::time::UNIX_EPOCH)
560	        .unwrap()
561	        .as_millis() as u64;
562	    let missing = std::env::temp_dir().join("spt-release-verify-no-such-keys.json");
563	    let policy = spt_daemon::release::VerifyPolicy::load_from(&missing, current_version, now_ms);
564
565	    match spt_daemon::release::verify_metadata(&signed, &policy) {
566	        Ok(meta) => {
567	            println!(
568	                "metadata VERIFIED: version={} channel={} key_id={} sha256={}",
569	                meta.version, meta.channel, meta.key_id, meta.artifact_sha256
570	            );
571	            if let Some(artifact) = artifact {
572	                let bytes = std::fs::read(&artifact).expect("read artifact");
573	                match spt_daemon::release::verify_artifact(&meta, &bytes) {
574	                    Ok(()) => println!("artifact VERIFIED: {}", artifact.display()),
575	                    Err(e) => {
576	                        eprintln!("artifact REJECTED: {e}");
577	                        std::process::exit(1);
578	                    }
579	                }
580	            }
581	        }
582	        Err(e) => {
583	            eprintln!("metadata REJECTED: {e}");
584	            std::process::exit(1);
585	        }
586	    }
587	}
588
589	fn copy_tree_all(from: &Path, to: &Path) {
590	    std::fs::create_dir_all(to).unwrap();
591	    for entry in std::fs::read_dir(from).expect("read dir") {
592	        let entry = entry.unwrap();
593	        let path = entry.path();
594	        if path.is_dir() {
595	            copy_tree_all(&path, &to.join(entry.file_name()));
596	        } else {
597	            std::fs::copy(&path, to.join(entry.file_name())).unwrap();
598	        }
599	    }
600	}
601

//! spt-core docs/site generation (M6-D5) — the anti-drift plumbing behind
//! DOCS-STRATEGY's "generation is part of the build" commitment (REQ-DOCS-5):
//!
//! - `gen` — regenerate the checked-in generated docs (CLI reference).
//! - `check` — drift gate: regenerate + diff the CLI reference, and
//!   validate every page link in the curated llms.txt.
//! - `site <outdir>` — assemble the full publish tree for the spt-releases
//!   Pages branch: mdBook HTML + raw `.md` alongside it + llms.txt /
//!   llms-full.txt / manifest.schema.json / install scripts at site root +
//!   rustdoc for the SDK crates.
//!
//! No deps; shells out to the workspace binary, mdbook, and cargo doc.
//
// [impl->REQ-DOCS-1]
// [impl->REQ-DOCS-4]

use std::path::{Path, PathBuf};
use std::process::Command;

fn repo_root() -> PathBuf {
    // crates/xtask -> crates -> repo root
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .ancestors()
        .nth(2)
        .expect("repo root")
        .to_path_buf()
}

fn main() {
    let args: Vec<String> = std::env::args().skip(1).collect();
    let cmd = args.first().map(String::as_str).unwrap_or("");
    match cmd {
        "gen" => gen(false),
        "check" => {
            gen(true);
            check_llms_links();
            eprintln!("xtask check: OK");
        }
        "site" => {
            let out = args.get(1).expect("usage: xtask site <outdir>");
            site(Path::new(out));
        }
        "release-keygen" => release_keygen(&args[1..]),
        "release-sign" => release_sign(&args[1..]),
        "release-publish" => release_publish(&args[1..]),
        "release-verify" => release_verify(&args[1..]),
        _ => {
            eprintln!(
                "usage: xtask <gen|check|site <outdir>|release-keygen <key-id>|release-sign …|release-publish …>"
            );
            std::process::exit(2);
        }
    }
}

// ---------------------------------------------------------------- CLI ref

/// Run the workspace `spt` binary with `args` + `--help`, normalized to the
/// `spt` name (Windows prints `spt.exe`, which would make the generated
/// reference platform-dependent).
fn help_for(bin: &Path, args: &[&str]) -> String {
    let mut cmd = Command::new(bin);
    cmd.args(args).arg("--help");
    let out = cmd.output().expect("run spt --help");
    let text = String::from_utf8_lossy(&out.stdout);
    text.replace("spt.exe", "spt").replace("\r\n", "\n")
}

/// Parse the `Commands:` block of a clap help text into subcommand names.
fn subcommands(help: &str) -> Vec<String> {
    let mut cmds = Vec::new();
    let mut in_block = false;
    for line in help.lines() {
        if line.trim_end() == "Commands:" {
            in_block = true;
            continue;
        }
        if in_block {
            // The block ends at the blank line before `Options:`.
            if line.trim().is_empty() || line.starts_with("Options:") {
                break;
            }
            // Entries are `  <name>  <doc…>`; continuation lines are deeper-indented.
            if let Some(rest) = line.strip_prefix("  ") {
                if !rest.starts_with(' ') {
                    if let Some(name) = rest.split_whitespace().next() {
                        if name != "help" {
                            cmds.push(name.to_string());
                        }
                    }
                }
            }
        }
    }
    cmds
}

/// The generated CLI reference: every command + nested subcommand's help,
/// verbatim. Help text is a first-class doc surface (DOCS-STRATEGY), so the
/// reference IS the binary's own words — it cannot drift (REQ-DOCS-5).
// [impl->REQ-DOCS-5]
fn cli_reference(bin: &Path) -> String {
    let mut out = String::from(
        "# CLI reference\n\n\
         > **Generated** from the `spt` binary's own `--help` output \
         (`cargo run -p xtask -- gen`) and drift-gated in CI — this page \
         cannot disagree with the binary. Do not edit by hand.\n\n",
    );
    let root_help = help_for(bin, &[]);
    out.push_str("## spt\n\n```text\n");
    out.push_str(&root_help);
    out.push_str("```\n");
    for cmd in subcommands(&root_help) {
        // `spt api` requires --adapter even for subcommand help; pass a
        // stand-in so clap renders the help instead of erroring.
        let help = if cmd == "api" {
            help_for(bin, &["api", "--adapter", "x"])
        } else {
            help_for(bin, &[&cmd])
        };
        out.push_str(&format!("\n## spt {cmd}\n\n```text\n{help}```\n"));
        for sub in subcommands(&help) {
            let sub_help = if cmd == "api" {
                help_for(bin, &["api", "--adapter", "x", &sub])
            } else {
                help_for(bin, &[&cmd, &sub])
            };
            out.push_str(&format!(
                "\n### spt {cmd} {sub}\n\n```text\n{sub_help}```\n"
            ));
        }
    }
    out
}

/// Locate (building if needed) the workspace `spt` binary.
fn spt_bin(root: &Path) -> PathBuf {
    let status = Command::new("cargo")
        .args(["build", "-q", "-p", "spt", "--bin", "spt"])
        .current_dir(root)
        .status()
        .expect("cargo build spt");
    assert!(status.success(), "building spt failed");
    let name = if cfg!(windows) { "spt.exe" } else { "spt" };
    root.join("target/debug").join(name)
}

fn gen(check: bool) {
    let root = repo_root();
    let bin = spt_bin(&root);
    let generated = cli_reference(&bin);
    let path = root.join("docs-site/src/cli/reference.md");
    if check {
        let on_disk = std::fs::read_to_string(&path)
            .expect("checked-in cli/reference.md (run `cargo run -p xtask -- gen`)")
            .replace("\r\n", "\n");
        if on_disk != generated {
            eprintln!(
                "xtask check: docs-site/src/cli/reference.md drifted from the \
                 binary's --help — run `cargo run -p xtask -- gen` and commit"
            );
            std::process::exit(1);
        }
    } else {
        std::fs::write(&path, &generated).expect("write cli reference");
        eprintln!("xtask gen: wrote {}", path.display());
    }
}

// ---------------------------------------------------------------- llms

/// Every relative `.md`/`.json`/`.sh`/`.ps1` link in the curated llms.txt
/// must resolve — agents cache links; a dead one is a contract break
/// (DOCS-STRATEGY principle 9).
fn check_llms_links() {
    let root = repo_root();
    let llms = std::fs::read_to_string(root.join("docs-site/llms.txt"))
        .expect("docs-site/llms.txt (the curated index)");
    let mut dead = Vec::new();
    for (idx, _) in llms.match_indices("](") {
        let rest = &llms[idx + 2..];
        let Some(end) = rest.find(')') else { continue };
        let target = &rest[..end];
        if target.starts_with("http://") || target.starts_with("https://") {
            continue;
        }
        let target = target.trim_start_matches('/');
        // Site-root assets are placed by `site`; page links map into src/.
        let exists = match target {
            "llms-full.txt" | "manifest.schema.json" => true,
            "install.sh" | "install.ps1" => true,
            t => root.join("docs-site/src").join(t).exists(),
        };
        if !exists {
            dead.push(target.to_string());
        }
    }
    if !dead.is_empty() {
        eprintln!("xtask check: dead links in docs-site/llms.txt: {dead:?}");
        std::process::exit(1);
    }
}

/// llms-full.txt: the CI-concatenated full export — every page in SUMMARY
/// order, fenced with its site path so agents can cite anchors
/// (generation-only, never hand-authored).
fn llms_full(root: &Path) -> String {
    let src = root.join("docs-site/src");
    let summary = std::fs::read_to_string(src.join("SUMMARY.md")).expect("SUMMARY.md");
    let mut out = String::from(
        "# spt-core — full docs export\n\
         # Generated: concatenation of every page of \
         https://sabermage.github.io/spt-releases in reading order.\n\n",
    );
    for (idx, _) in summary.match_indices("](") {
        let rest = &summary[idx + 2..];
        let Some(end) = rest.find(')') else { continue };
        let page = &rest[..end];
        if !page.ends_with(".md") {
            continue;
        }
        let body = std::fs::read_to_string(src.join(page))
            .unwrap_or_else(|_| panic!("SUMMARY links missing page {page}"));
        out.push_str(&format!("\n\n===== /{page} =====\n\n"));
        out.push_str(&body);
    }
    out
}

// ---------------------------------------------------------------- site

fn run(cmd: &mut Command, what: &str) {
    let status = cmd.status().unwrap_or_else(|e| panic!("{what}: {e}"));
    assert!(status.success(), "{what} failed");
}

fn copy_tree_md(from: &Path, to: &Path) {
    for entry in std::fs::read_dir(from).expect("read src dir") {
        let entry = entry.unwrap();
        let path = entry.path();
        if path.is_dir() {
            copy_tree_md(&path, &to.join(entry.file_name()));
        } else if path.extension().is_some_and(|e| e == "md") {
            std::fs::create_dir_all(to).unwrap();
            std::fs::copy(&path, to.join(entry.file_name())).unwrap();
        }
    }
}

/// Assemble the publish tree (the spt-releases Pages branch payload):
/// rendered HTML + the agent-export layer (raw .md alongside, llms exports,
/// schema, install scripts) + rustdoc for the public SDK crates.
// [impl->REQ-REL-1]
fn site(out: &Path) {
    let root = repo_root();
    // 1. mdBook HTML.
    run(
        Command::new("mdbook")
            .arg("build")
            .arg("--dest-dir")
            .arg(out.canonicalize().unwrap_or_else(|_| {
                std::fs::create_dir_all(out).unwrap();
                out.canonicalize().unwrap()
            }))
            .current_dir(root.join("docs-site")),
        "mdbook build",
    );
    // 2. Raw .md alongside the HTML (`/x.html` ↔ `/x.md` — the markdown
    //    content-negotiation convention; ~90% fewer tokens for agents).
    copy_tree_md(&root.join("docs-site/src"), out);
    // 3. Site-root assets at stable URLs (never renamed; ADR-0014).
    std::fs::write(out.join("llms-full.txt"), llms_full(&root)).unwrap();
    for (from, name) in [
        ("docs-site/llms.txt", "llms.txt"),
        ("crates/spt-runtime/manifest.schema.json", "manifest.schema.json"),
        ("installer/install.sh", "install.sh"),
        ("installer/install.ps1", "install.ps1"),
    ] {
        std::fs::copy(root.join(from), out.join(name))
            .unwrap_or_else(|e| panic!("copy {from}: {e}"));
    }
    // 4. rustdoc for the public SDK crates → /rustdoc.
    run(
        Command::new("cargo")
            .args([
                "doc", "--no-deps", "-p", "spt-proto", "-p", "spt-runtime", "-p", "spt-msg",
            ])
            .current_dir(&root),
        "cargo doc",
    );
    let rustdoc_out = out.join("rustdoc");
    if rustdoc_out.exists() {
        std::fs::remove_dir_all(&rustdoc_out).unwrap();
    }
    copy_tree_all(&root.join("target/doc"), &rustdoc_out);
    eprintln!("xtask site: publish tree assembled at {}", out.display());
}

// ------------------------------------------------------------ release (D6)

/// The key ceremony's minting half (ADR-0015): generate one Ed25519 release
/// keypair and print it to stdout — NOTHING is written to disk. The
/// maintainer runs this twice (primary + recovery), stores both seeds
/// off-machine (password manager + paper/QR, physically separate), embeds
/// both PUBLIC keys in `BUILTIN_RELEASE_KEYS` (spt-daemon::release), and
/// discards the terminal scrollback.
// [impl->REQ-REL-3]
fn release_keygen(args: &[String]) {
    let key_id = args.first().expect("usage: xtask release-keygen <key-id>");
    let id = spt_proto::identity::Identity::generate();
    let seed_hex: String = id.seed().iter().map(|b| format!("{b:02x}")).collect();
    println!("key_id:     {key_id}");
    println!("public_hex: {}", id.public_key().to_hex());
    println!("seed_hex:   {seed_hex}");
    println!();
    println!("1. Store seed_hex in your password manager (and a paper/QR backup,");
    println!("   physically separate). The seed never touches a repo or CI.");
    println!("2. Add (\"{key_id}\", \"<public_hex>\") to BUILTIN_RELEASE_KEYS in");
    println!("   crates/spt-daemon/src/release.rs and rebuild.");
    println!("3. Clear this terminal's scrollback.");
}

/// The manual local signing step (ADR-0015 — no CI secrets): produce the
/// exact `SignedRelease` JSON the self-updater consumes, one per platform
/// artifact. The signing seed comes from the SPT_RELEASE_SEED env var
/// (paste from the password manager into the shell session, never argv).
///
/// `xtask release-sign --key-id <id> --version <u64> [--expires-days <n>]
///  <artifact>…` writes `<artifact>.release.json` beside each artifact.
// [impl->REQ-REL-2]
// [impl->REQ-REL-3]
fn release_sign(args: &[String]) {
    let mut key_id = None;
    let mut version = None;
    let mut expires_days: u64 = 365;
    let mut artifacts: Vec<PathBuf> = Vec::new();
    let mut it = args.iter();
    while let Some(a) = it.next() {
        match a.as_str() {
            "--key-id" => key_id = it.next().cloned(),
            "--version" => version = it.next().and_then(|v| v.parse::<u64>().ok()),
            "--expires-days" => {
                expires_days = it.next().and_then(|v| v.parse().ok()).expect("--expires-days <n>")
            }
            other => artifacts.push(PathBuf::from(other)),
        }
    }
    let key_id = key_id.expect("--key-id <id> (must match a BUILTIN_RELEASE_KEYS id)");
    let version = version.expect("--version <u64> (the monotonic release counter)");
    assert!(!artifacts.is_empty(), "at least one artifact to sign");

    let identity = signing_identity();
    println!("signing as: {} (key_id {key_id})", identity.public_key().to_hex());
    sign_artifacts(&identity, &key_id, version, expires_days, &artifacts);
}

/// Resolve the signing identity. Preference order:
/// 1. `SPT_RELEASE_SEED_CMD` — a command that prints the seed hex (the
///    password-manager CLI integration, e.g.
///    `op read "op://Private/spt release primary/seed"` or
///    `bw get notes spt-release-primary`): the seed never sits in shell
///    history or a long-lived env var.
/// 2. `SPT_RELEASE_SEED` — the seed hex pasted into the env directly.
// [impl->REQ-REL-3]
fn signing_identity() -> spt_proto::identity::Identity {
    let seed_hex = if let Ok(cmd) = std::env::var("SPT_RELEASE_SEED_CMD") {
        let out = if cfg!(windows) {
            Command::new("powershell").args(["-NoProfile", "-Command", &cmd]).output()
        } else {
            Command::new("sh").args(["-c", &cmd]).output()
        }
        .expect("run SPT_RELEASE_SEED_CMD");
        assert!(
            out.status.success(),
            "SPT_RELEASE_SEED_CMD failed: {}",
            String::from_utf8_lossy(&out.stderr)
        );
        String::from_utf8_lossy(&out.stdout).trim().to_string()
    } else {
        std::env::var("SPT_RELEASE_SEED").expect(
            "set SPT_RELEASE_SEED_CMD (a command printing the seed, e.g. your \
             password manager's CLI) or SPT_RELEASE_SEED (the seed hex itself)",
        )
    };
    let seed: Vec<u8> = (0..seed_hex.len())
        .step_by(2)
        .map(|i| u8::from_str_radix(&seed_hex[i..i + 2], 16).expect("seed is hex"))
        .collect();
    let seed: [u8; 32] = seed.as_slice().try_into().expect("seed is 32 bytes");
    spt_proto::identity::Identity::from_seed(&seed)
}

/// Sign each artifact, writing `<artifact>.release.json` beside it.
// [impl->REQ-REL-2]
fn sign_artifacts(
    identity: &spt_proto::identity::Identity,
    key_id: &str,
    version: u64,
    expires_days: u64,
    artifacts: &[PathBuf],
) -> Vec<PathBuf> {
    let now_ms = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_millis() as u64;
    let mut outs = Vec::new();
    for artifact in artifacts {
        let bytes = std::fs::read(artifact).expect("read artifact");
        let meta = spt_daemon::release::ReleaseMetadata {
            version,
            channel: spt_daemon::release::DEFAULT_CHANNEL.to_string(),
            expires_at_ms: now_ms + expires_days * 24 * 60 * 60 * 1000,
            key_id: key_id.to_string(),
            artifact_sha256: spt_daemon::release::sha256_hex(&bytes),
            brain_ipc_version: spt_daemon::IPC_PROTOCOL_VERSION,
            broker_resource_abi: spt_daemon::update::BROKER_RESOURCE_ABI,
        };
        let metadata_json = serde_json::to_string(&meta).expect("serialize metadata");
        let sig = identity.sign(metadata_json.as_bytes());
        let sig_hex: String = sig.iter().map(|b| format!("{b:02x}")).collect();
        let signed = spt_daemon::release::SignedRelease { metadata_json, signature_hex: sig_hex };
        let out = artifact.with_extension(format!(
            "{}release.json",
            artifact
                .extension()
                .map(|e| format!("{}.", e.to_string_lossy()))
                .unwrap_or_default()
        ));
        std::fs::write(&out, serde_json::to_string_pretty(&signed).unwrap()).expect("write");
        println!(
            "signed {} (sha256 {}) -> {}",
            artifact.display(),
            meta.artifact_sha256,
            out.display()
        );
        outs.push(out);
    }
    outs
}

/// The whole maintainer half of a release in one local command (the manual
/// ceremony, automated — the seed still never leaves this machine):
///
/// `xtask release-publish --tag v0.1.0 --key-id rel-primary-2026 --version 1`
///
/// 1. download the CI draft's binaries + SHA256SUMS from spt-releases
/// 2. verify every binary against SHA256SUMS (never sign unverified bytes)
/// 3. sign each binary (seed via SPT_RELEASE_SEED_CMD / SPT_RELEASE_SEED)
/// 4. upload the *.release.json assets
/// 5. publish the draft
// [impl->REQ-REL-2]
fn release_publish(args: &[String]) {
    let mut tag = None;
    let mut key_id = None;
    let mut version = None;
    let mut expires_days: u64 = 365;
    let mut it = args.iter();
    while let Some(a) = it.next() {
        match a.as_str() {
            "--tag" => tag = it.next().cloned(),
            "--key-id" => key_id = it.next().cloned(),
            "--version" => version = it.next().and_then(|v| v.parse::<u64>().ok()),
            "--expires-days" => {
                expires_days = it.next().and_then(|v| v.parse().ok()).expect("--expires-days <n>")
            }
            other => panic!("unknown arg {other}"),
        }
    }
    let tag = tag.expect("--tag <vX.Y.Z>");
    let key_id = key_id.expect("--key-id <id>");
    let version = version.expect("--version <u64> (the monotonic release counter)");
    const REPO: &str = "SaberMage/spt-releases";

    // 1. Pull the draft's binaries + SHA256SUMS into a scratch dir.
    let work = std::env::temp_dir().join(format!("spt-release-{tag}"));
    let _ = std::fs::remove_dir_all(&work);
    std::fs::create_dir_all(&work).unwrap();
    run(
        Command::new("gh")
            .args([
                "release", "download", &tag, "--repo", REPO, "--dir",
                work.to_str().unwrap(), "--pattern", "spt-x86_64-*", "--pattern", "SHA256SUMS",
            ]),
        "gh release download",
    );

    // 2. Verify before signing — the signature must attest to exactly the
    //    bytes CI published, and those bytes must match its own SHA256SUMS.
    let sums = std::fs::read_to_string(work.join("SHA256SUMS")).expect("SHA256SUMS");
    let mut artifacts = Vec::new();
    for line in sums.lines() {
        let mut parts = line.split_whitespace();
        let (Some(want), Some(name)) = (parts.next(), parts.next()) else { continue };
        let name = name.trim_start_matches('*');
        if name.ends_with(".release.json") {
            continue;
        }
        let path = work.join(name);
        let bytes = std::fs::read(&path)
            .unwrap_or_else(|e| panic!("downloaded asset {name} missing: {e}"));
        let got = spt_daemon::release::sha256_hex(&bytes);
        assert_eq!(got, want.to_ascii_lowercase(), "{name}: sha256 mismatch — refusing to sign");
        println!("verified {name} ({got})");
        artifacts.push(path);
    }
    assert!(!artifacts.is_empty(), "no binaries found in the draft release");

    // 3. Sign.
    let identity = signing_identity();
    println!("signing as: {} (key_id {key_id})", identity.public_key().to_hex());
    let signed = sign_artifacts(&identity, &key_id, version, expires_days, &artifacts);

    // 4. Upload the SignedRelease assets.
    let mut upload = Command::new("gh");
    upload.args(["release", "upload", &tag, "--repo", REPO, "--clobber"]);
    for s in &signed {
        upload.arg(s);
    }
    run(&mut upload, "gh release upload");

    // 5. Publish.
    run(
        Command::new("gh").args(["release", "edit", &tag, "--repo", REPO, "--draft=false"]),
        "gh release edit --draft=false",
    );
    println!("release {tag} signed + published.");
}

/// The `spt update` dry path, runnable by hand: verify a SignedRelease
/// metadata file (and optionally its artifact bytes) against the **embedded**
/// two-key trust anchor — exactly the policy a deployed node applies to a
/// propagated release. The D7 acceptance verifier.
///
/// `xtask release-verify <signed.release.json> [artifact] [--current-version <u64>]`
// [impl->REQ-REL-2]
fn release_verify(args: &[String]) {
    let mut signed_path = None;
    let mut artifact = None;
    let mut current_version: u64 = 0;
    let mut it = args.iter();
    while let Some(a) = it.next() {
        match a.as_str() {
            "--current-version" => {
                current_version =
                    it.next().and_then(|v| v.parse().ok()).expect("--current-version <u64>")
            }
            other if signed_path.is_none() => signed_path = Some(PathBuf::from(other)),
            other => artifact = Some(PathBuf::from(other)),
        }
    }
    let signed_path = signed_path.expect("usage: release-verify <signed.json> [artifact]");
    let raw = std::fs::read_to_string(&signed_path).expect("read signed metadata");
    let signed: spt_daemon::release::SignedRelease =
        serde_json::from_str(&raw).expect("parse SignedRelease JSON");

    // The embedded anchor, exactly as a fresh node assembles it (no keys
    // file): builtin set + default channel.
    let now_ms = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_millis() as u64;
    let missing = std::env::temp_dir().join("spt-release-verify-no-such-keys.json");
    let policy = spt_daemon::release::VerifyPolicy::load_from(&missing, current_version, now_ms);

    match spt_daemon::release::verify_metadata(&signed, &policy) {
        Ok(meta) => {
            println!(
                "metadata VERIFIED: version={} channel={} key_id={} sha256={}",
                meta.version, meta.channel, meta.key_id, meta.artifact_sha256
            );
            if let Some(artifact) = artifact {
                let bytes = std::fs::read(&artifact).expect("read artifact");
                match spt_daemon::release::verify_artifact(&meta, &bytes) {
                    Ok(()) => println!("artifact VERIFIED: {}", artifact.display()),
                    Err(e) => {
                        eprintln!("artifact REJECTED: {e}");
                        std::process::exit(1);
                    }
                }
            }
        }
        Err(e) => {
            eprintln!("metadata REJECTED: {e}");
            std::process::exit(1);
        }
    }
}

fn copy_tree_all(from: &Path, to: &Path) {
    std::fs::create_dir_all(to).unwrap();
    for entry in std::fs::read_dir(from).expect("read dir") {
        let entry = entry.unwrap();
        let path = entry.path();
        if path.is_dir() {
            copy_tree_all(&path, &to.join(entry.file_name()));
        } else {
            std::fs::copy(&path, to.join(entry.file_name())).unwrap();
        }
    }
}


Edit

clap = { version = "4", features = ["derive"] }

# `wrap_help`: wrap long help text to the terminal width with a hanging indent
# (without it clap emits unwrapped lines and the terminal hard-wraps to col 0).
clap = { version = "4", features = ["derive", "wrap_help"] }


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

# `wrap_help`: wrap long help text to the terminal width with a hanging indent
# (without it clap emits unwrapped lines and the terminal hard-wraps to col 0).
clap = { version = "4", features = ["derive", "wrap_help"] }

[package]
name = "spt"
version.workspace = true
edition = "2021"
publish = false
description = "spt-core CLI: the single `spt` binary. Thin command layer over spt-msg (send/ring/ready/poll/list/stop/whoami) + the harness-contract `api` surface (spt-runtime)."

[[bin]]
name = "spt"
path = "src/main.rs"

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
spt-live = { path = "../spt-live" }
spt-daemon = { path = "../spt-daemon" }
# The reference binary ships the WAN transport on by default (ADR-0002). D2g reuses its
# RFC-6238 TotpSeed (spt_net::net::pairing::totp) to render a subnet's local pairing
# code — reused, not reimplemented, so the CLI code matches the ceremony's exactly.
spt-net = { path = "../spt-net" }
clap = { version = "4", features = ["derive"] }
# The WAN send leg (D9-1-4) serializes the dial address + reads registry
# snapshots - same pinned shape as spt-daemon's.
serde_json = { version = "1", features = ["preserve_order"] }

# OS privilege-elevation probe for the node-bound code fetch (REQ-PAIR-6). Only the
# binary pulls the OS deps; the core stays dep-minimal. Matches the workspace's existing
# libc/windows-sys usage (spt-msg, spt-store) rather than the heavyweight `windows`.
[target.'cfg(unix)'.dependencies]
libc = "0.2"

[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = [
    "Win32_Foundation",
    "Win32_Security",
    "Win32_System_Threading",
] }

[dev-dependencies]
tempfile = "3"
toml = "0.8"
# install_e2e stages the built binary as a release asset (SHA256SUMS) for the
# D2 installer-script rung — same sha2 the workspace already carries.
sha2 = "0.10"

-clap = { version = "4", features = ["derive"] }

+# `wrap_help`: wrap long help text to the terminal width with a hanging indent

+# (without it clap emits unwrapped lines and the terminal hard-wraps to col 0).

+clap = { version = "4", features = ["derive", "wrap_help"] }


Now cli.rs — split doc comments into short `about` + `long_about`:


    /// Send a message (body read from stdin). Fire-and-forget.
    Send {

    /// Send a message (body read from stdin); fire-and-forget.
    Send {


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)

    /// Send a message (body read from stdin). Fire-and-forget.
    Send {

    /// Send a message (body read from stdin); fire-and-forget.
    Send {

-    /// Send a message (body read from stdin). Fire-and-forget.

+    /// Send a message (body read from stdin); fire-and-forget.

/// Target perch id. May be omitted when --reply-to is given.


    /// Send and block for a reply (body read from stdin; reply printed to stdout).
    Ring {

    /// Send and block for a reply (body read from stdin).
    ///
    /// The reply body is printed to stdout; gives up after --timeout seconds.
    Ring {


    /// Send and block for a reply (body read from stdin; reply printed to stdout).
    Ring {

    /// Send and block for a reply (body read from stdin).
    ///
    /// The reply body is printed to stdout; gives up after --timeout seconds.
    Ring {

-    /// Send and block for a reply (body read from stdin; reply printed to stdout).

+    /// Send and block for a reply (body read from stdin).

+    /// The reply body is printed to stdout; gives up after --timeout seconds.


    /// Subnet pairing: show this node's current pairing code (and mint new
    /// subnets). Pairing is how machines join a private subnet.
    Pair {

    /// Subnet pairing: show this node's current pairing code.
    ///
    /// Also mints new subnets (`show-totp --create-new`). Pairing is how
    /// machines join a private subnet.
    Pair {


    /// Subnet pairing: show this node's current pairing code (and mint new
    /// subnets). Pairing is how machines join a private subnet.
    Pair {

    /// Subnet pairing: show this node's current pairing code.
    ///
    /// Also mints new subnets (`show-totp --create-new`). Pairing is how
    /// machines join a private subnet.
    Pair {

/// Print this session's own perch id (from $OWL_SESSION_ID / $SPT_AGENT_ID).

-    /// Subnet pairing: show this node's current pairing code (and mint new

-    /// subnets). Pairing is how machines join a private subnet.

+    /// Subnet pairing: show this node's current pairing code.

+    /// Also mints new subnets (`show-totp --create-new`). Pairing is how

+    /// machines join a private subnet.


    /// Show a hosted session's live activity buffer (PTY digest) — the
    /// at-a-glance "what is this agent doing now" view. Pulls a snapshot,
    /// or `--follow`s the delta-stream. Local endpoints only.
    Digest {

    /// Show a hosted session's live activity buffer (PTY digest).
    ///
    /// The at-a-glance "what is this agent doing now" view. Pulls a
    /// snapshot, or `--follow`s the delta-stream. Local endpoints only.
    Digest {


    /// Show a hosted session's live activity buffer (PTY digest) — the
    /// at-a-glance "what is this agent doing now" view. Pulls a snapshot,
    /// or `--follow`s the delta-stream. Local endpoints only.
    Digest {

    /// Show a hosted session's live activity buffer (PTY digest).
    ///
    /// The at-a-glance "what is this agent doing now" view. Pulls a
    /// snapshot, or `--follow`s the delta-stream. Local endpoints only.
    Digest {

-    /// Show a hosted session's live activity buffer (PTY digest) — the

-    /// at-a-glance "what is this agent doing now" view. Pulls a snapshot,

-    /// or `--follow`s the delta-stream. Local endpoints only.

+    /// Show a hosted session's live activity buffer (PTY digest).

+    /// The at-a-glance "what is this agent doing now" view. Pulls a

+    /// snapshot, or `--follow`s the delta-stream. Local endpoints only.


    /// Rename an endpoint's logical id, rippled across its on-disk state:
    /// the endpoint's perch dir, its nested companion/worker perches, and
    /// every record naming it. Refuses while the perch is live (stop it
    /// first).
    Rename {

    /// Rename an endpoint's logical id across its on-disk state.
    ///
    /// Rippled everywhere the id appears: the endpoint's perch dir, its
    /// nested companion/worker perches, and every record naming it.
    /// Refuses while the perch is live (stop it first).
    Rename {


    /// Rename an endpoint's logical id, rippled across its on-disk state:
    /// the endpoint's perch dir, its nested companion/worker perches, and
    /// every record naming it. Refuses while the perch is live (stop it
    /// first).
    Rename {

    /// Rename an endpoint's logical id across its on-disk state.
    ///
    /// Rippled everywhere the id appears: the endpoint's perch dir, its
    /// nested companion/worker perches, and every record naming it.
    /// Refuses while the perch is live (stop it first).
    Rename {

-    /// Rename an endpoint's logical id, rippled across its on-disk state:

-    /// the endpoint's perch dir, its nested companion/worker perches, and

-    /// every record naming it. Refuses while the perch is live (stop it

+    /// Rename an endpoint's logical id across its on-disk state.

+    /// Rippled everywhere the id appears: the endpoint's perch dir, its

+    /// nested companion/worker perches, and every record naming it.

+    /// Refuses while the perch is live (stop it first).


    /// Endpoint access whitelist: which origin nodes may send an endpoint
    /// unsolicited off-node inbound. Absent entry = open;
    /// `allow` flips the endpoint to restricted; revoking the last node leaves
    /// it locked down; `open` deletes the restriction.
    Access {

    /// Endpoint access whitelist for unsolicited off-node inbound.
    ///
    /// Controls which origin nodes may send an endpoint unsolicited
    /// off-node inbound. Absent entry = open; `allow` flips the endpoint
    /// to restricted; revoking the last node leaves it locked down;
    /// `open` deletes the restriction.
    Access {


    /// Endpoint access whitelist: which origin nodes may send an endpoint
    /// unsolicited off-node inbound. Absent entry = open;
    /// `allow` flips the endpoint to restricted; revoking the last node leaves
    /// it locked down; `open` deletes the restriction.
    Access {

    /// Endpoint access whitelist for unsolicited off-node inbound.
    ///
    /// Controls which origin nodes may send an endpoint unsolicited
    /// off-node inbound. Absent entry = open; `allow` flips the endpoint
    /// to restricted; revoking the last node leaves it locked down;
    /// `open` deletes the restriction.
    Access {

/// The new (bare) id — charset-validated; `:`/`@` are reserved.

-    /// Endpoint access whitelist: which origin nodes may send an endpoint

-    /// unsolicited off-node inbound. Absent entry = open;

-    /// `allow` flips the endpoint to restricted; revoking the last node leaves

-    /// it locked down; `open` deletes the restriction.

+    /// Endpoint access whitelist for unsolicited off-node inbound.

+    /// Controls which origin nodes may send an endpoint unsolicited

+    /// off-node inbound. Absent entry = open; `allow` flips the endpoint

+    /// to restricted; revoking the last node leaves it locked down;

+    /// `open` deletes the restriction.


    /// Consent grant store: which agents hold which gated capabilities on
    /// this node. Default-deny (the access whitelist's opposite polarity).
    /// An ungranted ask escalates interactively; `add` is the durable
    /// allow-always answer.
    Grant {

    /// Consent grant store: gated capabilities agents hold on this node.
    ///
    /// Default-deny (the access whitelist's opposite polarity). An
    /// ungranted ask escalates interactively; `add` is the durable
    /// allow-always answer.
    Grant {


    /// Consent grant store: which agents hold which gated capabilities on
    /// this node. Default-deny (the access whitelist's opposite polarity).
    /// An ungranted ask escalates interactively; `add` is the durable
    /// allow-always answer.
    Grant {

    /// Consent grant store: gated capabilities agents hold on this node.
    ///
    /// Default-deny (the access whitelist's opposite polarity). An
    /// ungranted ask escalates interactively; `add` is the durable
    /// allow-always answer.
    Grant {

-    /// Consent grant store: which agents hold which gated capabilities on

-    /// this node. Default-deny (the access whitelist's opposite polarity).

-    /// An ungranted ask escalates interactively; `add` is the durable

+    /// Consent grant store: gated capabilities agents hold on this node.

+    /// Default-deny (the access whitelist's opposite polarity). An

+    /// ungranted ask escalates interactively; `add` is the durable


    /// Adapter registration: the node-local registered set — what this node
    /// can drive/launch (one command for harness and shell adapters). The
    /// registered set feeds creation-time adapter selection, shell
    /// discovery, and the self-update ripple.
    Adapter {

    /// Adapter registration: what this node can drive/launch.
    ///
    /// The node-local registered set (one command for harness and shell
    /// adapters). Feeds creation-time adapter selection, shell discovery,
    /// and the self-update ripple.
    Adapter {


    /// Adapter registration: the node-local registered set — what this node
    /// can drive/launch (one command for harness and shell adapters). The
    /// registered set feeds creation-time adapter selection, shell
    /// discovery, and the self-update ripple.
    Adapter {

    /// Adapter registration: what this node can drive/launch.
    ///
    /// The node-local registered set (one command for harness and shell
    /// adapters). Feeds creation-time adapter selection, shell discovery,
    /// and the self-update ripple.
    Adapter {

-    /// Adapter registration: the node-local registered set — what this node

-    /// can drive/launch (one command for harness and shell adapters). The

-    /// registered set feeds creation-time adapter selection, shell

-    /// discovery, and the self-update ripple.

+    /// Adapter registration: what this node can drive/launch.

+    /// The node-local registered set (one command for harness and shell

+    /// adapters). Feeds creation-time adapter selection, shell discovery,


    /// Shell instances: mint, list, drive, and tear down the driven surfaces
    /// this agent owns. `spawn` MINTS a new instance identity
    /// (`<adapter>-<n>`) — it is not the online switch; bringing an existing
    /// offline instance back is `relink` / `persistent` / wake.
    Shell {

    /// Shell instances: mint, list, drive, tear down owned surfaces.
    ///
    /// The driven surfaces this agent owns. `spawn` MINTS a new instance
    /// identity (`<adapter>-<n>`) — it is not the online switch; bringing
    /// an existing offline instance back is `relink` / `persistent` /
    /// wake.
    Shell {


    /// Shell instances: mint, list, drive, and tear down the driven surfaces
    /// this agent owns. `spawn` MINTS a new instance identity
    /// (`<adapter>-<n>`) — it is not the online switch; bringing an existing
    /// offline instance back is `relink` / `persistent` / wake.
    Shell {

    /// Shell instances: mint, list, drive, tear down owned surfaces.
    ///
    /// The driven surfaces this agent owns. `spawn` MINTS a new instance
    /// identity (`<adapter>-<n>`) — it is not the online switch; bringing
    /// an existing offline instance back is `relink` / `persistent` /
    /// wake.
    Shell {

-    /// Shell instances: mint, list, drive, and tear down the driven surfaces

-    /// this agent owns. `spawn` MINTS a new instance identity

-    /// (`<adapter>-<n>`) — it is not the online switch; bringing an existing

-    /// offline instance back is `relink` / `persistent` / wake.

+    /// Shell instances: mint, list, drive, tear down owned surfaces.

+    /// The driven surfaces this agent owns. `spawn` MINTS a new instance

+    /// identity (`<adapter>-<n>`) — it is not the online switch; bringing

+    /// an existing offline instance back is `relink` / `persistent` /


    /// Issue a subnet-wide user notification: produced into the replicated
    /// notification spool and first-fired at the user's most-recently-active
    /// endpoint in that subnet. Body from the trailing arg, or stdin when
    /// omitted.
    Notify {

    /// Issue a subnet-wide user notification.
    ///
    /// Produced into the replicated notification spool and first-fired at
    /// the user's most-recently-active endpoint in that subnet. Body from
    /// the trailing arg, or stdin when omitted.
    Notify {


    /// Issue a subnet-wide user notification: produced into the replicated
    /// notification spool and first-fired at the user's most-recently-active
    /// endpoint in that subnet. Body from the trailing arg, or stdin when
    /// omitted.
    Notify {

    /// Issue a subnet-wide user notification.
    ///
    /// Produced into the replicated notification spool and first-fired at
    /// the user's most-recently-active endpoint in that subnet. Body from
    /// the trailing arg, or stdin when omitted.
    Notify {

-    /// Issue a subnet-wide user notification: produced into the replicated

-    /// notification spool and first-fired at the user's most-recently-active

-    /// endpoint in that subnet. Body from the trailing arg, or stdin when

+    /// Issue a subnet-wide user notification.

+    /// Produced into the replicated notification spool and first-fired at

+    /// the user's most-recently-active endpoint in that subnet. Body from

+    /// the trailing arg, or stdin when omitted.

/// Notification body (read from stdin when omitted).


    /// Inspect and acknowledge notifications. Dismissal is the explicit
    /// ack — it latches and replicates subnet-wide.
    Notif {

    /// Inspect and acknowledge notifications.
    ///
    /// Dismissal is the explicit ack — it latches and replicates
    /// subnet-wide.
    Notif {


    /// Inspect and acknowledge notifications. Dismissal is the explicit
    /// ack — it latches and replicates subnet-wide.
    Notif {

    /// Inspect and acknowledge notifications.
    ///
    /// Dismissal is the explicit ack — it latches and replicates
    /// subnet-wide.
    Notif {

-    /// Inspect and acknowledge notifications. Dismissal is the explicit

-    /// ack — it latches and replicates subnet-wide.

+    /// Inspect and acknowledge notifications.

+    /// Dismissal is the explicit ack — it latches and replicates


    /// Self-update operations. `apply` is the explicit ack named by the
    /// update-consent notification; it re-verifies the staged release before
    /// touching the live daemon.
    Update {

    /// Self-update operations.
    ///
    /// `apply` is the explicit ack named by the update-consent
    /// notification; it re-verifies the staged release before touching
    /// the live daemon.
    Update {


    /// Self-update operations. `apply` is the explicit ack named by the
    /// update-consent notification; it re-verifies the staged release before
    /// touching the live daemon.
    Update {

    /// Self-update operations.
    ///
    /// `apply` is the explicit ack named by the update-consent
    /// notification; it re-verifies the staged release before touching
    /// the live daemon.
    Update {

-    /// Self-update operations. `apply` is the explicit ack named by the

-    /// update-consent notification; it re-verifies the staged release before

+    /// `apply` is the explicit ack named by the update-consent

+    /// notification; it re-verifies the staged release before touching


    /// Fork an endpoint into another subnet as a NEW identity (home subnets
    /// are immutable — fork is the cross-subnet move, never a re-home).
    /// Seeds the fork with a one-time copy of the source's mind (live +
    /// project tiers); the two diverge immediately (no ongoing sync). The
    /// source is untouched unless --delete-source. Same-node only today
    /// (one node holds one perch per name, so a local fork needs a new id).
    Fork {

    /// Fork an endpoint into another subnet as a NEW identity.
    ///
    /// Home subnets are immutable — fork is the cross-subnet move, never
    /// a re-home. Seeds the fork with a one-time copy of the source's
    /// mind (live + project tiers); the two diverge immediately (no
    /// ongoing sync). The source is untouched unless --delete-source.
    /// Same-node only today (one node holds one perch per name, so a
    /// local fork needs a new id).
    Fork {


    /// Fork an endpoint into another subnet as a NEW identity (home subnets
    /// are immutable — fork is the cross-subnet move, never a re-home).
    /// Seeds the fork with a one-time copy of the source's mind (live +
    /// project tiers); the two diverge immediately (no ongoing sync). The
    /// source is untouched unless --delete-source. Same-node only today
    /// (one node holds one perch per name, so a local fork needs a new id).
    Fork {

    /// Fork an endpoint into another subnet as a NEW identity.
    ///
    /// Home subnets are immutable — fork is the cross-subnet move, never
    /// a re-home. Seeds the fork with a one-time copy of the source's
    /// mind (live + project tiers); the two diverge immediately (no
    /// ongoing sync). The source is untouched unless --delete-source.
    /// Same-node only today (one node holds one perch per name, so a
    /// local fork needs a new id).
    Fork {

-    /// Fork an endpoint into another subnet as a NEW identity (home subnets

-    /// are immutable — fork is the cross-subnet move, never a re-home).

-    /// Seeds the fork with a one-time copy of the source's mind (live +

-    /// project tiers); the two diverge immediately (no ongoing sync). The

-    /// source is untouched unless --delete-source. Same-node only today

-    /// (one node holds one perch per name, so a local fork needs a new id).

+    /// Fork an endpoint into another subnet as a NEW identity.

+    /// Home subnets are immutable — fork is the cross-subnet move, never

+    /// a re-home. Seeds the fork with a one-time copy of the source's

+    /// mind (live + project tiers); the two diverge immediately (no

+    /// ongoing sync). The source is untouched unless --delete-source.

+    /// Same-node only today (one node holds one perch per name, so a

/// The source endpoint (must exist on this node).


    /// The subnet resource registry: per-endpoint free-text service blurbs
    /// — an agent yellow-pages. `set` authors your own, `show` reads a
    /// local endpoint's, `list` projects the registry view over visible
    /// rows.
    Resources {

    /// The subnet resource registry — an agent yellow-pages.
    ///
    /// Per-endpoint free-text service blurbs. `set` authors your own,
    /// `show` reads a local endpoint's, `list` projects the registry view
    /// over visible rows.
    Resources {


    /// The subnet resource registry: per-endpoint free-text service blurbs
    /// — an agent yellow-pages. `set` authors your own, `show` reads a
    /// local endpoint's, `list` projects the registry view over visible
    /// rows.
    Resources {

    /// The subnet resource registry — an agent yellow-pages.
    ///
    /// Per-endpoint free-text service blurbs. `set` authors your own,
    /// `show` reads a local endpoint's, `list` projects the registry view
    /// over visible rows.
    Resources {

-    /// The subnet resource registry: per-endpoint free-text service blurbs

-    /// — an agent yellow-pages. `set` authors your own, `show` reads a

-    /// local endpoint's, `list` projects the registry view over visible

+    /// The subnet resource registry — an agent yellow-pages.

+    /// Per-endpoint free-text service blurbs. `set` authors your own,

+    /// `show` reads a local endpoint's, `list` projects the registry view


    /// Rest an endpoint cold: the resting state machine's suspend edge.
    /// From dormant — or straight from active, in which case the final
    /// context save still fires first. Accepts a qualified `id@node` to
    /// suspend an instance on a paired peer.
    Suspend {

    /// Rest an endpoint cold (the resting state machine's suspend edge).
    ///
    /// From dormant — or straight from active, in which case the final
    /// context save still fires first. Accepts a qualified `id@node` to
    /// suspend an instance on a paired peer.
    Suspend {


    /// Rest an endpoint cold: the resting state machine's suspend edge.
    /// From dormant — or straight from active, in which case the final
    /// context save still fires first. Accepts a qualified `id@node` to
    /// suspend an instance on a paired peer.
    Suspend {

    /// Rest an endpoint cold (the resting state machine's suspend edge).
    ///
    /// From dormant — or straight from active, in which case the final
    /// context save still fires first. Accepts a qualified `id@node` to
    /// suspend an instance on a paired peer.
    Suspend {

-    /// Rest an endpoint cold: the resting state machine's suspend edge.

+    /// Rest an endpoint cold (the resting state machine's suspend edge).


    /// Wake a resting endpoint in place: re-activates the existing seat
    /// (state's already there — no fresh spawn), resurfaces undismissed
    /// notifications, and requests an immediate context freshness pull from
    /// trusted peers. Accepts a qualified `id@node` for an instance on a
    /// paired peer.
    Wake {

    /// Wake a resting endpoint in place.
    ///
    /// Re-activates the existing seat (state's already there — no fresh
    /// spawn), resurfaces undismissed notifications, and requests an
    /// immediate context freshness pull from trusted peers. Accepts a
    /// qualified `id@node` for an instance on a paired peer.
    Wake {


    /// Wake a resting endpoint in place: re-activates the existing seat
    /// (state's already there — no fresh spawn), resurfaces undismissed
    /// notifications, and requests an immediate context freshness pull from
    /// trusted peers. Accepts a qualified `id@node` for an instance on a
    /// paired peer.
    Wake {

    /// Wake a resting endpoint in place.
    ///
    /// Re-activates the existing seat (state's already there — no fresh
    /// spawn), resurfaces undismissed notifications, and requests an
    /// immediate context freshness pull from trusted peers. Accepts a
    /// qualified `id@node` for an instance on a paired peer.
    Wake {

/// The endpoint id (qualified `id@node` reaches a paired peer).

-    /// Wake a resting endpoint in place: re-activates the existing seat

-    /// (state's already there — no fresh spawn), resurfaces undismissed

-    /// notifications, and requests an immediate context freshness pull from

-    /// trusted peers. Accepts a qualified `id@node` for an instance on a

+    /// Wake a resting endpoint in place.

+    /// Re-activates the existing seat (state's already there — no fresh

+    /// spawn), resurfaces undismissed notifications, and requests an

+    /// immediate context freshness pull from trusted peers. Accepts a

+    /// qualified `id@node` for an instance on a paired peer.


    /// Gracefully shut down an agent's own endpoint: soft-stop the listener,
    /// then the suspend edge — the final context save fires and persistent
    /// shells cascade offline with it.
    Shutdown {

    /// Gracefully shut down an agent's own endpoint.
    ///
    /// Soft-stops the listener, then the suspend edge — the final context
    /// save fires and persistent shells cascade offline with it.
    Shutdown {


    /// Gracefully shut down an agent's own endpoint: soft-stop the listener,
    /// then the suspend edge — the final context save fires and persistent
    /// shells cascade offline with it.
    Shutdown {

    /// Gracefully shut down an agent's own endpoint.
    ///
    /// Soft-stops the listener, then the suspend edge — the final context
    /// save fires and persistent shells cascade offline with it.
    Shutdown {

-    /// Gracefully shut down an agent's own endpoint: soft-stop the listener,

-    /// then the suspend edge — the final context save fires and persistent

-    /// shells cascade offline with it.

+    /// Gracefully shut down an agent's own endpoint.

+    /// Soft-stops the listener, then the suspend edge — the final context

+    /// save fires and persistent shells cascade offline with it.

/// The endpoint id (defaults to the session's own perch).


    /// Harness-contract inbound surface: the entry points a harness's hooks
    /// fire to keep spt-core's on-disk state in sync.
    Api(crate::api::ApiArgs),

    /// Harness-contract inbound surface (hook entry points).
    ///
    /// The entry points a harness's hooks fire to keep spt-core's on-disk
    /// state in sync.
    Api(crate::api::ApiArgs),


    /// Harness-contract inbound surface: the entry points a harness's hooks
    /// fire to keep spt-core's on-disk state in sync.
    Api(crate::api::ApiArgs),

    /// Harness-contract inbound surface (hook entry points).
    ///
    /// The entry points a harness's hooks fire to keep spt-core's on-disk
    /// state in sync.
    Api(crate::api::ApiArgs),

-    /// Harness-contract inbound surface: the entry points a harness's hooks

-    /// fire to keep spt-core's on-disk state in sync.

+    /// Harness-contract inbound surface (hook entry points).

+    /// The entry points a harness's hooks fire to keep spt-core's on-disk

/// Run the per-machine daemon. Internal: any `spt` invocation that needs

/// it auto-starts it; operators rarely run it directly.


Remaining long one-liners — trim:


    /// Become reachable: register the perch and listen for messages (blocks).
    Ready {

    /// Become reachable: register the perch and listen (blocks).
    Ready {


    /// Become reachable: register the perch and listen for messages (blocks).
    Ready {

    /// Become reachable: register the perch and listen (blocks).
    Ready {

-    /// Become reachable: register the perch and listen for messages (blocks).

+    /// Become reachable: register the perch and listen (blocks).


    /// Receive messages: drain backlog then listen, printing each to stdout.
    Poll {

    /// Receive messages: drain backlog, then listen.
    ///
    /// Each received message prints to stdout.
    Poll {


    /// Receive messages: drain backlog then listen, printing each to stdout.
    Poll {

    /// Receive messages: drain backlog, then listen.
    ///
    /// Each received message prints to stdout.
    Poll {

-    /// Receive messages: drain backlog then listen, printing each to stdout.

+    /// Receive messages: drain backlog, then listen.

+    /// Each received message prints to stdout.


    /// Soft-stop a perch: remove the ready marker + unregister (spool preserved).
    Stop {

    /// Soft-stop a perch (spool preserved).
    ///
    /// Removes the ready marker and unregisters the perch; the spool is
    /// preserved.
    Stop {


    /// Soft-stop a perch: remove the ready marker + unregister (spool preserved).
    Stop {

    /// Soft-stop a perch (spool preserved).
    ///
    /// Removes the ready marker and unregisters the perch; the spool is
    /// preserved.
    Stop {

/// List this node's perches (id, state, address, ready, alive).

-    /// Soft-stop a perch: remove the ready marker + unregister (spool preserved).

+    /// Soft-stop a perch (spool preserved).

+    /// Removes the ready marker and unregisters the perch; the spool is


    /// Print this session's own perch id (from $OWL_SESSION_ID / $SPT_AGENT_ID).
    Whoami,

    /// Print this session's own perch id.
    ///
    /// Resolved from $OWL_SESSION_ID / $SPT_AGENT_ID.
    Whoami,