//!   claude-spt digest      — the [digest] extractor (was claude-spt-digest)
//!   claude-spt psyche      — the [session.psyche_init] Psyche runner (was claude-spt-psyche)
//!   claude-spt post-update — reconcile the cplugs plugin after `spt adapter update` (ADR-0006)
//!   claude-spt translate   — the [message-idle-translation-binary] idle filter (was cc-spt-idle-translate)
//!
//! Dispatch is a bare `argv[1]` match (no clap — keep the dependency-light ethos the predecessor
//! crates were built on). Each subcommand owns its remaining argv via `std::env::args().skip(2)`
//! (skip the binary name + the subcommand token) and its own hand-written flag parser. `translate`
//! reads no argv (pure stdin/stdout protocol).
//!
//! `translate` folded in at the v0.8.0 cut once spt-core v0.16.0 gave `[message-idle-translation-
//! binary]` a `command` field (D3) — so claude-spt is now the SINGLE tool binary (one artifact per
//! triple in adapter.spt). [impl->REQ-DIST-BINARY-CONSOLIDATE] [impl->REQ-DIST-IDLE-TRANSLATE]

//! claude-spt — consolidated tool binary for the claude-spt adapter (ADR-0006, U2).
//!
//! ONE binary, three subcommands (was three crates → fewer artifacts per triple in adapter.spt):
//!   claude-spt digest      — the [digest] extractor (was claude-spt-digest)
//!   claude-spt psyche      — the [session.psyche_init] Psyche runner (was claude-spt-psyche)
//!   claude-spt post-update — reconcile the cplugs plugin after `spt adapter update` (NEW, ADR-0006)
//!
//! Dispatch is a bare `argv[1]` match (no clap — keep the dependency-light ethos both predecessor
//! crates were built on). Each subcommand owns its remaining argv via `std::env::args().skip(2)`
//! (skip the binary name + the subcommand token) and its own hand-written flag parser.
//!
//! `cc-spt-idle-translate` stays a SEPARATE binary this milestone — it folds into a `claude-spt
//! translate` subcommand only once doyle ask #3 lets `[message-idle-translation-binary]` take a
//! command/subcommand rather than a bare `path` (D3). [impl->REQ-DIST-BINARY-CONSOLIDATE]

mod digest;
mod post_update;
mod psyche;
mod translate;

use std::process::ExitCode;

/// The resolved subcommand. Pure classification of `argv[1]` so dispatch routing is unit-testable
/// without spawning the binary. [impl->REQ-DIST-BINARY-CONSOLIDATE]
#[derive(Debug, PartialEq)]
enum Sub {
    Digest,
    Psyche,
    PostUpdate,
    Translate,
    Help,
    Unknown(String),
}

fn classify(sub: Option<&str>) -> Sub {
    match sub {
        Some("digest") => Sub::Digest,
        Some("psyche") => Sub::Psyche,
        Some("post-update") => Sub::PostUpdate,
        Some("translate") => Sub::Translate,
        None | Some("-h") | Some("--help") => Sub::Help,
        Some(other) => Sub::Unknown(other.to_string()),
    }
}

fn usage() {
    eprintln!(
        "claude-spt <subcommand> [args]\n\
         \n\
         subcommands:\n\
         \x20 digest       map a Claude Code JSONL transcript to digest NDJSON ([digest] extractor)\n\
         \x20 psyche       run the LiveAgent Psyche companion ([session.psyche_init] runner)\n\
         \x20 post-update  reconcile the cplugs plugin after `spt adapter update`"
    );
}

fn main() -> ExitCode {
    let sub = std::env::args().nth(1);
    match classify(sub.as_deref()) {
        Sub::Digest => digest::run(),
        Sub::Psyche => psyche::run(),
        Sub::PostUpdate => post_update::run(),
        Sub::Translate => translate::run(),
        Sub::Help => {
            usage();
            ExitCode::SUCCESS
        }
        Sub::Unknown(other) => {
            eprintln!("claude-spt: unknown subcommand: {other}");
            usage();
            ExitCode::from(2)
        }
    }
}

// [unit->REQ-DIST-BINARY-CONSOLIDATE]
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn classifies_each_real_subcommand() {
        assert_eq!(classify(Some("digest")), Sub::Digest);
        assert_eq!(classify(Some("psyche")), Sub::Psyche);
        assert_eq!(classify(Some("post-update")), Sub::PostUpdate);
    }

    #[test]
    fn no_subcommand_and_help_flags_are_help() {
        assert_eq!(classify(None), Sub::Help);
        assert_eq!(classify(Some("-h")), Sub::Help);
        assert_eq!(classify(Some("--help")), Sub::Help);
    }

    #[test]
    fn unrecognized_subcommand_is_unknown_not_misrouted() {
        // A typo must NOT silently fall through to a real subcommand (it exits 2 in main).
        assert_eq!(classify(Some("digset")), Sub::Unknown("digset".into()));
    }
}

-//!   claude-spt post-update — reconcile the cplugs plugin after `spt adapter update` (NEW, ADR-0006)

+//!   claude-spt post-update — reconcile the cplugs plugin after `spt adapter update` (ADR-0006)

+//!   claude-spt translate   — the [message-idle-translation-binary] idle filter (was cc-spt-idle-translate)

-//! Dispatch is a bare `argv[1]` match (no clap — keep the dependency-light ethos both predecessor

+//! Dispatch is a bare `argv[1]` match (no clap — keep the dependency-light ethos the predecessor

-//! (skip the binary name + the subcommand token) and its own hand-written flag parser.

+//! (skip the binary name + the subcommand token) and its own hand-written flag parser. `translate`

+//! reads no argv (pure stdin/stdout protocol).

-//! `cc-spt-idle-translate` stays a SEPARATE binary this milestone — it folds into a `claude-spt

-//! translate` subcommand only once doyle ask #3 lets `[message-idle-translation-binary]` take a

-//! command/subcommand rather than a bare `path` (D3). [impl->REQ-DIST-BINARY-CONSOLIDATE]

+//! `translate` folded in at the v0.8.0 cut once spt-core v0.16.0 gave `[message-idle-translation-

+//! binary]` a `command` field (D3) — so claude-spt is now the SINGLE tool binary (one artifact per

+//! triple in adapter.spt). [impl->REQ-DIST-BINARY-CONSOLIDATE] [impl->REQ-DIST-IDLE-TRANSLATE]


Edit

         \x20 post-update  reconcile the cplugs plugin after `spt adapter update`"
    );

         \x20 post-update  reconcile the cplugs plugin after `spt adapter update`\n\
         \x20 translate    idle-message translation filter (stdin->stdout JSON lines)"
    );


proceed with the wiring and gates. spt-core is updated. publish when everything is ready.


Verify PreToolUse message polling and injection


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

         \x20 post-update  reconcile the cplugs plugin after `spt adapter update`\n\
         \x20 translate    idle-message translation filter (stdin->stdout JSON lines)"
    );

//! claude-spt — consolidated tool binary for the claude-spt adapter (ADR-0006, U2).
//!
//! ONE binary, three subcommands (was three crates → fewer artifacts per triple in adapter.spt):
//!   claude-spt digest      — the [digest] extractor (was claude-spt-digest)
//!   claude-spt psyche      — the [session.psyche_init] Psyche runner (was claude-spt-psyche)
//!   claude-spt post-update — reconcile the cplugs plugin after `spt adapter update` (ADR-0006)
//!   claude-spt translate   — the [message-idle-translation-binary] idle filter (was cc-spt-idle-translate)
//!
//! Dispatch is a bare `argv[1]` match (no clap — keep the dependency-light ethos the predecessor
//! crates were built on). Each subcommand owns its remaining argv via `std::env::args().skip(2)`
//! (skip the binary name + the subcommand token) and its own hand-written flag parser. `translate`
//! reads no argv (pure stdin/stdout protocol).
//!
//! `translate` folded in at the v0.8.0 cut once spt-core v0.16.0 gave `[message-idle-translation-
//! binary]` a `command` field (D3) — so claude-spt is now the SINGLE tool binary (one artifact per
//! triple in adapter.spt). [impl->REQ-DIST-BINARY-CONSOLIDATE] [impl->REQ-DIST-IDLE-TRANSLATE]

mod digest;
mod post_update;
mod psyche;
mod translate;

use std::process::ExitCode;

/// The resolved subcommand. Pure classification of `argv[1]` so dispatch routing is unit-testable
/// without spawning the binary. [impl->REQ-DIST-BINARY-CONSOLIDATE]
#[derive(Debug, PartialEq)]
enum Sub {
    Digest,
    Psyche,
    PostUpdate,
    Translate,
    Help,
    Unknown(String),
}

fn classify(sub: Option<&str>) -> Sub {
    match sub {
        Some("digest") => Sub::Digest,
        Some("psyche") => Sub::Psyche,
        Some("post-update") => Sub::PostUpdate,
        Some("translate") => Sub::Translate,
        None | Some("-h") | Some("--help") => Sub::Help,
        Some(other) => Sub::Unknown(other.to_string()),
    }
}

fn usage() {
    eprintln!(
        "claude-spt <subcommand> [args]\n\
         \n\
         subcommands:\n\
         \x20 digest       map a Claude Code JSONL transcript to digest NDJSON ([digest] extractor)\n\
         \x20 psyche       run the LiveAgent Psyche companion ([session.psyche_init] runner)\n\
         \x20 post-update  reconcile the cplugs plugin after `spt adapter update`"
    );
}

fn main() -> ExitCode {
    let sub = std::env::args().nth(1);
    match classify(sub.as_deref()) {
        Sub::Digest => digest::run(),
        Sub::Psyche => psyche::run(),
        Sub::PostUpdate => post_update::run(),
        Sub::Translate => translate::run(),
        Sub::Help => {
            usage();
            ExitCode::SUCCESS
        }
        Sub::Unknown(other) => {
            eprintln!("claude-spt: unknown subcommand: {other}");
            usage();
            ExitCode::from(2)
        }
    }
}

// [unit->REQ-DIST-BINARY-CONSOLIDATE]
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn classifies_each_real_subcommand() {
        assert_eq!(classify(Some("digest")), Sub::Digest);
        assert_eq!(classify(Some("psyche")), Sub::Psyche);
        assert_eq!(classify(Some("post-update")), Sub::PostUpdate);
    }

    #[test]
    fn no_subcommand_and_help_flags_are_help() {
        assert_eq!(classify(None), Sub::Help);
        assert_eq!(classify(Some("-h")), Sub::Help);
        assert_eq!(classify(Some("--help")), Sub::Help);
    }

    #[test]
    fn unrecognized_subcommand_is_unknown_not_misrouted() {
        // A typo must NOT silently fall through to a real subcommand (it exits 2 in main).
        assert_eq!(classify(Some("digset")), Sub::Unknown("digset".into()));
    }
}

-         \x20 post-update  reconcile the cplugs plugin after `spt adapter update`"

+         \x20 post-update  reconcile the cplugs plugin after `spt adapter update`\n\

+         \x20 translate    idle-message translation filter (stdin->stdout JSON lines)"


        assert_eq!(classify(Some("digest")), Sub::Digest);
        assert_eq!(classify(Some("psyche")), Sub::Psyche);
        assert_eq!(classify(Some("post-update")), Sub::PostUpdate);

        assert_eq!(classify(Some("digest")), Sub::Digest);
        assert_eq!(classify(Some("psyche")), Sub::Psyche);
        assert_eq!(classify(Some("post-update")), Sub::PostUpdate);
        assert_eq!(classify(Some("translate")), Sub::Translate);


        assert_eq!(classify(Some("digest")), Sub::Digest);
        assert_eq!(classify(Some("psyche")), Sub::Psyche);
        assert_eq!(classify(Some("post-update")), Sub::PostUpdate);
        assert_eq!(classify(Some("translate")), Sub::Translate);

//! claude-spt — consolidated tool binary for the claude-spt adapter (ADR-0006, U2).
//!
//! ONE binary, three subcommands (was three crates → fewer artifacts per triple in adapter.spt):
//!   claude-spt digest      — the [digest] extractor (was claude-spt-digest)
//!   claude-spt psyche      — the [session.psyche_init] Psyche runner (was claude-spt-psyche)
//!   claude-spt post-update — reconcile the cplugs plugin after `spt adapter update` (ADR-0006)
//!   claude-spt translate   — the [message-idle-translation-binary] idle filter (was cc-spt-idle-translate)
//!
//! Dispatch is a bare `argv[1]` match (no clap — keep the dependency-light ethos the predecessor
//! crates were built on). Each subcommand owns its remaining argv via `std::env::args().skip(2)`
//! (skip the binary name + the subcommand token) and its own hand-written flag parser. `translate`
//! reads no argv (pure stdin/stdout protocol).
//!
//! `translate` folded in at the v0.8.0 cut once spt-core v0.16.0 gave `[message-idle-translation-
//! binary]` a `command` field (D3) — so claude-spt is now the SINGLE tool binary (one artifact per
//! triple in adapter.spt). [impl->REQ-DIST-BINARY-CONSOLIDATE] [impl->REQ-DIST-IDLE-TRANSLATE]

mod digest;
mod post_update;
mod psyche;
mod translate;

use std::process::ExitCode;

/// The resolved subcommand. Pure classification of `argv[1]` so dispatch routing is unit-testable
/// without spawning the binary. [impl->REQ-DIST-BINARY-CONSOLIDATE]
#[derive(Debug, PartialEq)]
enum Sub {
    Digest,
    Psyche,
    PostUpdate,
    Translate,
    Help,
    Unknown(String),
}

fn classify(sub: Option<&str>) -> Sub {
    match sub {
        Some("digest") => Sub::Digest,
        Some("psyche") => Sub::Psyche,
        Some("post-update") => Sub::PostUpdate,
        Some("translate") => Sub::Translate,
        None | Some("-h") | Some("--help") => Sub::Help,
        Some(other) => Sub::Unknown(other.to_string()),
    }
}

fn usage() {
    eprintln!(
        "claude-spt <subcommand> [args]\n\
         \n\
         subcommands:\n\
         \x20 digest       map a Claude Code JSONL transcript to digest NDJSON ([digest] extractor)\n\
         \x20 psyche       run the LiveAgent Psyche companion ([session.psyche_init] runner)\n\
         \x20 post-update  reconcile the cplugs plugin after `spt adapter update`\n\
         \x20 translate    idle-message translation filter (stdin->stdout JSON lines)"
    );
}

fn main() -> ExitCode {
    let sub = std::env::args().nth(1);
    match classify(sub.as_deref()) {
        Sub::Digest => digest::run(),
        Sub::Psyche => psyche::run(),
        Sub::PostUpdate => post_update::run(),
        Sub::Translate => translate::run(),
        Sub::Help => {
            usage();
            ExitCode::SUCCESS
        }
        Sub::Unknown(other) => {
            eprintln!("claude-spt: unknown subcommand: {other}");
            usage();
            ExitCode::from(2)
        }
    }
}

// [unit->REQ-DIST-BINARY-CONSOLIDATE]
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn classifies_each_real_subcommand() {
        assert_eq!(classify(Some("digest")), Sub::Digest);
        assert_eq!(classify(Some("psyche")), Sub::Psyche);
        assert_eq!(classify(Some("post-update")), Sub::PostUpdate);
    }

    #[test]
    fn no_subcommand_and_help_flags_are_help() {
        assert_eq!(classify(None), Sub::Help);
        assert_eq!(classify(Some("-h")), Sub::Help);
        assert_eq!(classify(Some("--help")), Sub::Help);
    }

    #[test]
    fn unrecognized_subcommand_is_unknown_not_misrouted() {
        // A typo must NOT silently fall through to a real subcommand (it exits 2 in main).
        assert_eq!(classify(Some("digset")), Sub::Unknown("digset".into()));
    }
}

+        assert_eq!(classify(Some("translate")), Sub::Translate);


Build + test the consolidated crate (now with translate folded in):


Bash

cd "C:/Users/decid/Documents/projects/spt-claude-code/tools/claude-spt" && cargo test --quiet 2>&1 | tail -8

Build+test consolidated crate with translate


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


59/59 pass (37 + 22 folded). Now rewire the manifest seam + packaging. Read the `[message-idle-translation-binary]` section:


cd "C:/Users/decid/Documents/projects/spt-claude-code" && grep -nE 'message-idle-translation-binary|^path = |^command = "cc' adapter/claude-spt.toml

Locate idle-translate manifest seam


37:# [session.resume] + [message-idle-translation-binary], added in 0.6.0). Fresh tag, not an in-place
42:# the [message-idle-translation-binary] choreography now ends with {key:enter} after the text. Binary
176:# the endpoint's own [message-idle-translation-binary], which fires the clear+wake macro — an
271:# controller / the [message-idle-translation-binary] use, so the endpoint id IS the RC session name.
310:# ── [message-idle-translation-binary] — agnostic idle PTY delivery (harness-contract v0.13.0+) ─
333:[message-idle-translation-binary]
334:path = "cc-spt-idle-translate"
392:command = "ccs --dangerously-skip-permissions"


310	# ── [message-idle-translation-binary] — agnostic idle PTY delivery (harness-contract v0.13.0+) ─
311	# The IDLE-delivery seam for spt-HOSTED sessions: a lifecycle-managed stdin→stdout JSON-lines binary
312	# spt-core spawns when the endpoint comes online and reaps on shutdown. It turns each inbound <EVENT>
313	# envelope into keystroke-commands spt-core applies ATOMICALLY to the broker-held PTY (operator input
314	# is buffered during emission, so it coexists with an attached `spt rc` controller). This is the
315	# spt-hosted complement to the [inject] hook channel above: [inject] handles BUSY/mid-turn delivery
316	# (UPS additionalContext); this handles IDLE delivery into a session sitting at its input box.
317	#
318	# `path` = the binary's bare name (resolved from the adapter install dir like the consolidated
319	# claude-spt binary; it packs into adapter.spt). The binary (tools/cc-spt-idle-translate, Rust) emits
320	# the operator-specified choreography per message — ctrl+s (stash draft) · 50ms · <envelope>\r
321	# (submit line) · {"commit":true} (terminate sequence) — so an inbound message never clobbers a
322	# half-typed draft (CC AUTO-RESTORES the stashed draft after submit, so no trailing restore keystroke).
323	# TWO distinct signals (doyle-confirmed from broker.rs): (1) the trailing \r submits the PTY line —
324	# spt-core applies a {"text"} command VERBATIM (no control-char strip; broker.rs:1066/:1016-1017),
325	# equivalent to {"key":"enter"}; that verbatim-apply is also why the binary neutralizes the envelope's
326	# internal CR/LF. (2) {"commit":true} is the MANDATORY inject-sequence terminator: run_inject_worker
327	# (broker.rs:1075-1090) ends a sequence ONLY on {commit}; without it the broker FAULTs at the 5s
328	# INJECT_COMMIT_DEADLINE on every delivery. \r submits the line; {commit} releases the InjectFloor.
329	# RUNTIME dogfood (the spawn-on-up/reap-on-down lifecycle + atomic PTY apply) needs the v0.13.0
330	# daemon; the binary + manifest declaration are buildable against the published contract now (docs
331	# lead the binary — still v0.12.x on spt-releases). Satisfies REQ-DIST-IDLE-TRANSLATE (the scanned
332	# impl evidence is the binary tools/cc-spt-idle-translate/src/main.rs; .toml is unscanned).
333	[message-idle-translation-binary]
334	path = "cc-spt-idle-translate"

# ── [message-idle-translation-binary] — agnostic idle PTY delivery (harness-contract v0.13.0+) ─
# The IDLE-delivery seam for spt-HOSTED sessions: a lifecycle-managed stdin→stdout JSON-lines binary
# spt-core spawns when the endpoint comes online and reaps on shutdown. It turns each inbound <EVENT>
# envelope into keystroke-commands spt-core applies ATOMICALLY to the broker-held PTY (operator input
# is buffered during emission, so it coexists with an attached `spt rc` controller). This is the
# spt-hosted complement to the [inject] hook channel above: [inject] handles BUSY/mid-turn delivery
# (UPS additionalContext); this handles IDLE delivery into a session sitting at its input box.
#
# `path` = the binary's bare name (resolved from the adapter install dir like the consolidated
# claude-spt binary; it packs into adapter.spt). The binary (tools/cc-spt-idle-translate, Rust) emits
# the operator-specified choreography per message — ctrl+s (stash draft) · 50ms · <envelope>\r
# (submit line) · {"commit":true} (terminate sequence) — so an inbound message never clobbers a
# half-typed draft (CC AUTO-RESTORES the stashed draft after submit, so no trailing restore keystroke).
# TWO distinct signals (doyle-confirmed from broker.rs): (1) the trailing \r submits the PTY line —
# spt-core applies a {"text"} command VERBATIM (no control-char strip; broker.rs:1066/:1016-1017),
# equivalent to {"key":"enter"}; that verbatim-apply is also why the binary neutralizes the envelope's
# internal CR/LF. (2) {"commit":true} is the MANDATORY inject-sequence terminator: run_inject_worker
# (broker.rs:1075-1090) ends a sequence ONLY on {commit}; without it the broker FAULTs at the 5s
# INJECT_COMMIT_DEADLINE on every delivery. \r submits the line; {commit} releases the InjectFloor.
# RUNTIME dogfood (the spawn-on-up/reap-on-down lifecycle + atomic PTY apply) needs the v0.13.0
# daemon; the binary + manifest declaration are buildable against the published contract now (docs
# lead the binary — still v0.12.x on spt-releases). Satisfies REQ-DIST-IDLE-TRANSLATE (the scanned
# impl evidence is the binary tools/cc-spt-idle-translate/src/main.rs; .toml is unscanned).
[message-idle-translation-binary]
path = "cc-spt-idle-translate"


Edit

# `path` = the binary's bare name (resolved from the adapter install dir like the consolidated
# claude-spt binary; it packs into adapter.spt). The binary (tools/cc-spt-idle-translate, Rust) emits
# the operator-specified choreography per message — ctrl+s (stash draft) · 50ms · <envelope>\r
# (submit line) · {"commit":true} (terminate sequence) — so an inbound message never clobbers a
# half-typed draft (CC AUTO-RESTORES the stashed draft after submit, so no trailing restore keystroke).
# TWO distinct signals (doyle-confirmed from broker.rs): (1) the trailing \r submits the PTY line —
# spt-core applies a {"text"} command VERBATIM (no control-char strip; broker.rs:1066/:1016-1017),
# equivalent to {"key":"enter"}; that verbatim-apply is also why the binary neutralizes the envelope's
# internal CR/LF. (2) {"commit":true} is the MANDATORY inject-sequence terminator: run_inject_worker
# (broker.rs:1075-1090) ends a sequence ONLY on {commit}; without it the broker FAULTs at the 5s
# INJECT_COMMIT_DEADLINE on every delivery. \r submits the line; {commit} releases the InjectFloor.
# RUNTIME dogfood (the spawn-on-up/reap-on-down lifecycle + atomic PTY apply) needs the v0.13.0
# daemon; the binary + manifest declaration are buildable against the published contract now (docs
# lead the binary — still v0.12.x on spt-releases). Satisfies REQ-DIST-IDLE-TRANSLATE (the scanned
# impl evidence is the binary tools/cc-spt-idle-translate/src/main.rs; .toml is unscanned).
[message-idle-translation-binary]
path = "cc-spt-idle-translate"

# `command` = the program+args spt-core spawns (spt-core v0.16.0 seam; `path` deprecated, exactly one
# of {path,command}). `{adapter_dir}` substitutes the install dir (survives updates), so the filter is
# now the `translate` subcommand of the ONE consolidated claude-spt binary — D3 fold, ADR-0006 (was
# the standalone cc-spt-idle-translate; protocol unchanged). The subcommand emits the operator-specified
# choreography per message — ctrl+s (stash draft) · 50ms · <envelope> · 50ms · {"key":"enter"} (submit)
# · {"commit":true} (terminate sequence) — so an inbound message never clobbers a half-typed draft (CC
# AUTO-RESTORES the stashed draft after submit, so no trailing restore keystroke). TWO distinct signals
# (doyle-confirmed from broker.rs): (1) the discrete {"key":"enter"} submits the PTY line (a verbatim \r
# byte does NOT submit a CC message — corrected 2026-06-23); the binary still neutralizes the envelope's
# internal CR/LF since spt-core applies {"text"} VERBATIM (broker.rs:1016-1017). (2) {"commit":true} is
# the MANDATORY inject-sequence terminator: run_inject_worker (broker.rs:1075-1090) ends a sequence ONLY
# on {commit}; without it the broker FAULTs at the 5s INJECT_COMMIT_DEADLINE. Satisfies
# REQ-DIST-IDLE-TRANSLATE (scanned impl evidence = tools/claude-spt/src/translate.rs; .toml is unscanned).
[message-idle-translation-binary]
command = "{adapter_dir}/claude-spt translate"


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

# `path` = the binary's bare name (resolved from the adapter install dir like the consolidated
# claude-spt binary; it packs into adapter.spt). The binary (tools/cc-spt-idle-translate, Rust) emits
# the operator-specified choreography per message — ctrl+s (stash draft) · 50ms · <envelope>\r
# (submit line) · {"commit":true} (terminate sequence) — so an inbound message never clobbers a
# half-typed draft (CC AUTO-RESTORES the stashed draft after submit, so no trailing restore keystroke).
# TWO distinct signals (doyle-confirmed from broker.rs): (1) the trailing \r submits the PTY line —
# spt-core applies a {"text"} command VERBATIM (no control-char strip; broker.rs:1066/:1016-1017),
# equivalent to {"key":"enter"}; that verbatim-apply is also why the binary neutralizes the envelope's
# internal CR/LF. (2) {"commit":true} is the MANDATORY inject-sequence terminator: run_inject_worker
# (broker.rs:1075-1090) ends a sequence ONLY on {commit}; without it the broker FAULTs at the 5s
# INJECT_COMMIT_DEADLINE on every delivery. \r submits the line; {commit} releases the InjectFloor.
# RUNTIME dogfood (the spawn-on-up/reap-on-down lifecycle + atomic PTY apply) needs the v0.13.0
# daemon; the binary + manifest declaration are buildable against the published contract now (docs
# lead the binary — still v0.12.x on spt-releases). Satisfies REQ-DIST-IDLE-TRANSLATE (the scanned
# impl evidence is the binary tools/cc-spt-idle-translate/src/main.rs; .toml is unscanned).
[message-idle-translation-binary]
path = "cc-spt-idle-translate"

# `command` = the program+args spt-core spawns (spt-core v0.16.0 seam; `path` deprecated, exactly one
# of {path,command}). `{adapter_dir}` substitutes the install dir (survives updates), so the filter is
# now the `translate` subcommand of the ONE consolidated claude-spt binary — D3 fold, ADR-0006 (was
# the standalone cc-spt-idle-translate; protocol unchanged). The subcommand emits the operator-specified
# choreography per message — ctrl+s (stash draft) · 50ms · <envelope> · 50ms · {"key":"enter"} (submit)
# · {"commit":true} (terminate sequence) — so an inbound message never clobbers a half-typed draft (CC
# AUTO-RESTORES the stashed draft after submit, so no trailing restore keystroke). TWO distinct signals
# (doyle-confirmed from broker.rs): (1) the discrete {"key":"enter"} submits the PTY line (a verbatim \r
# byte does NOT submit a CC message — corrected 2026-06-23); the binary still neutralizes the envelope's
# internal CR/LF since spt-core applies {"text"} VERBATIM (broker.rs:1016-1017). (2) {"commit":true} is
# the MANDATORY inject-sequence terminator: run_inject_worker (broker.rs:1075-1090) ends a sequence ONLY
# on {commit}; without it the broker FAULTs at the 5s INJECT_COMMIT_DEADLINE. Satisfies
# REQ-DIST-IDLE-TRANSLATE (scanned impl evidence = tools/claude-spt/src/translate.rs; .toml is unscanned).
[message-idle-translation-binary]
command = "{adapter_dir}/claude-spt translate"

# spt-hosted complement to the [inject] hook channel above: [inject] handles BUSY/mid-turn delivery

# (UPS additionalContext); this handles IDLE delivery into a session sitting at its input box.

-# `path` = the binary's bare name (resolved from the adapter install dir like the consolidated

-# claude-spt binary; it packs into adapter.spt). The binary (tools/cc-spt-idle-translate, Rust) emits

-# the operator-specified choreography per message — ctrl+s (stash draft) · 50ms · <envelope>\r

-# (submit line) · {"commit":true} (terminate sequence) — so an inbound message never clobbers a

-# half-typed draft (CC AUTO-RESTORES the stashed draft after submit, so no trailing restore keystroke).

-# TWO distinct signals (doyle-confirmed from broker.rs): (1) the trailing \r submits the PTY line —

-# spt-core applies a {"text"} command VERBATIM (no control-char strip; broker.rs:1066/:1016-1017),

-# equivalent to {"key":"enter"}; that verbatim-apply is also why the binary neutralizes the envelope's

-# internal CR/LF. (2) {"commit":true} is the MANDATORY inject-sequence terminator: run_inject_worker

-# (broker.rs:1075-1090) ends a sequence ONLY on {commit}; without it the broker FAULTs at the 5s

-# INJECT_COMMIT_DEADLINE on every delivery. \r submits the line; {commit} releases the InjectFloor.

-# RUNTIME dogfood (the spawn-on-up/reap-on-down lifecycle + atomic PTY apply) needs the v0.13.0

-# daemon; the binary + manifest declaration are buildable against the published contract now (docs

-# lead the binary — still v0.12.x on spt-releases). Satisfies REQ-DIST-IDLE-TRANSLATE (the scanned

-# impl evidence is the binary tools/cc-spt-idle-translate/src/main.rs; .toml is unscanned).

+# `command` = the program+args spt-core spawns (spt-core v0.16.0 seam; `path` deprecated, exactly one

+# of {path,command}). `{adapter_dir}` substitutes the install dir (survives updates), so the filter is

+# now the `translate` subcommand of the ONE consolidated claude-spt binary — D3 fold, ADR-0006 (was

+# the standalone cc-spt-idle-translate; protocol unchanged). The subcommand emits the operator-specified

+# choreography per message — ctrl+s (stash draft) · 50ms · <envelope> · 50ms · {"key":"enter"} (submit)

+# · {"commit":true} (terminate sequence) — so an inbound message never clobbers a half-typed draft (CC

+# AUTO-RESTORES the stashed draft after submit, so no trailing restore keystroke). TWO distinct signals

+# (doyle-confirmed from broker.rs): (1) the discrete {"key":"enter"} submits the PTY line (a verbatim \r

+# byte does NOT submit a CC message — corrected 2026-06-23); the binary still neutralizes the envelope's

+# internal CR/LF since spt-core applies {"text"} VERBATIM (broker.rs:1016-1017). (2) {"commit":true} is

+# the MANDATORY inject-sequence terminator: run_inject_worker (broker.rs:1075-1090) ends a sequence ONLY

+# on {commit}; without it the broker FAULTs at the 5s INJECT_COMMIT_DEADLINE. Satisfies

+# REQ-DIST-IDLE-TRANSLATE (scanned impl evidence = tools/claude-spt/src/translate.rs; .toml is unscanned).

+command = "{adapter_dir}/claude-spt translate"

# ── [digest] ───────────────────────────────────────────────────────────────────────────────

# Session-digest extractor seam. CC's transcript is JSONL (one event per line); the


Now delete the folded crate + rewire packaging/build/proof. Delete crate, read the proof-int:


Bash

cd "C:/Users/decid/Documents/projects/spt-claude-code" && rm -rf tools/cc-spt-idle-translate && echo "deleted crate; tools now:" && ls tools/; echo "=== translate-proof-int RELDIR/binary refs ==="; grep -nE 'cc-spt-idle-translate|RELDIR|target/release|--dir|translate-proof' ci/idle-translate/translate-proof-int.sh | head

Delete crate + inspect proof-int


deleted crate; tools now:
claude-spt
=== translate-proof-int RELDIR/binary refs ===
2:# Integration proof: `spt adapter translate-proof` spawns + feeds the claude-spt
3:# [message-idle-translation-binary] (cc-spt-idle-translate) EXACTLY as the daemon does at idle
9:# Uses the v0.13.2 `--dir`/`--manifest` override (F-011 closed, W5) to proof the DEV binary straight
10:# from its build dir against the bare-file manifest: --dir resolves the binary (before PATH) exactly
14:# spt >= 0.13.2 (the --dir/--manifest options) + a built binary. Idempotent.
15:# Run: sh ci/idle-translate/translate-proof-int.sh   (exit 0 = pass).
24:RELDIR="$ROOT/tools/cc-spt-idle-translate/target/release"
25:EVENT='<EVENT type="msg" from="ci">translate-proof int probe</EVENT>'
29:# --dir/--manifest landed in spt v0.13.2 — capability-detect rather than version-parse.
30:if ! spt adapter translate-proof --help 2>&1 | grep -q -- '--dir'; then


24	RELDIR="$ROOT/tools/cc-spt-idle-translate/target/release"
25	EVENT='<EVENT type="msg" from="ci">translate-proof int probe</EVENT>'
26
27	command -v spt >/dev/null 2>&1 || { echo "SKIP: no spt on PATH"; exit 0; }
28
29	# --dir/--manifest landed in spt v0.13.2 — capability-detect rather than version-parse.
30	if ! spt adapter translate-proof --help 2>&1 | grep -q -- '--dir'; then
31	  echo "SKIP: spt 'adapter translate-proof' has no --dir/--manifest (needs v0.13.2). Binary itself: cargo tests green."
32	  exit 0
33	fi
34
35	BIN="$RELDIR/cc-spt-idle-translate"
36	[ -x "$BIN" ] || BIN="$RELDIR/cc-spt-idle-translate.exe"
37	[ -x "$BIN" ] || { echo "SKIP: binary not built (run sh ci/idle-translate/build.sh)"; exit 0; }
38
39	# Proof the dev binary in-place: --dir resolves the binary (before PATH) like the daemon, --manifest
40	# pins the bare-file gh_release manifest — no extracted install, no registry touch.
41	out=$(spt adapter translate-proof claude-spt --event "$EVENT" --manifest "$MANIFEST" --dir "$RELDIR" 2>&1)
42	rc=0
43	case "$out" in

RELDIR="$ROOT/tools/cc-spt-idle-translate/target/release"
EVENT='<EVENT type="msg" from="ci">translate-proof int probe</EVENT>'

command -v spt >/dev/null 2>&1 || { echo "SKIP: no spt on PATH"; exit 0; }

# --dir/--manifest landed in spt v0.13.2 — capability-detect rather than version-parse.
if ! spt adapter translate-proof --help 2>&1 | grep -q -- '--dir'; then
  echo "SKIP: spt 'adapter translate-proof' has no --dir/--manifest (needs v0.13.2). Binary itself: cargo tests green."
  exit 0
fi

BIN="$RELDIR/cc-spt-idle-translate"
[ -x "$BIN" ] || BIN="$RELDIR/cc-spt-idle-translate.exe"
[ -x "$BIN" ] || { echo "SKIP: binary not built (run sh ci/idle-translate/build.sh)"; exit 0; }

# Proof the dev binary in-place: --dir resolves the binary (before PATH) like the daemon, --manifest
# pins the bare-file gh_release manifest — no extracted install, no registry touch.
out=$(spt adapter translate-proof claude-spt --event "$EVENT" --manifest "$MANIFEST" --dir "$RELDIR" 2>&1)
rc=0
case "$out" in


Edit

RELDIR="$ROOT/tools/claude-spt/target/release"   # consolidated binary; manifest command = {adapter_dir}/claude-spt translate (D3)


The file C:\Users\decid\Documents\projects\spt-claude-code\ci\idle-translate\translate-proof-int.sh has been updated successfully. (file state is current in your context — no need to Read it back)

#!/bin/sh
# Integration proof: `spt adapter translate-proof` spawns + feeds the claude-spt
# [message-idle-translation-binary] (cc-spt-idle-translate) EXACTLY as the daemon does at idle
# delivery (init line then the --event envelope), reads back the emitted keystroke-command stream,
# and gates it. This is the EMIT half of REQ-DIST-IDLE-TRANSLATE — the cross-process author-time proof
# (ADR-0022, the EMIT-half mirror of digest-proof). It does NOT exercise the daemon's atomic PTY APPLY
# or controller buffering — that half stays the real-claude bringup int (deferred). [int->REQ-DIST-IDLE-TRANSLATE]
#
# Uses the v0.13.2 `--dir`/`--manifest` override (F-011 closed, W5) to proof the DEV binary straight
# from its build dir against the bare-file manifest: --dir resolves the binary (before PATH) exactly
# as the daemon does, --manifest pins the bare-file manifest. NO registry mutation — the prior form
# staged a disposable install dir and `adapter add`/`adapter remove claude-spt`, which soft-removed
# the REAL registered claude-spt on cleanup. Read-only now, so NO SPTC_ACCEPTANCE gate. Needs
# spt >= 0.13.2 (the --dir/--manifest options) + a built binary. Idempotent.
# Run: sh ci/idle-translate/translate-proof-int.sh   (exit 0 = pass).
#
# GREEN against the {commit}-terminated binary: emits the 6-command choreography ctrl+s · 50ms ·
# {text:"<envelope>"} · 50ms · {key:enter} · {commit:true}, exit 0 / TRANSLATE_PROOF_OK / "commit: yes".
# The submit is a discrete {key:enter} AFTER the text (a trailing \r byte does NOT submit a CC message,
# corrected 2026-06-23). The no-{commit} form FAULTs (the F-016 defect the proof's no-commit gate catches).
set -u
ROOT=$(CDPATH= cd "$(dirname "$0")/../.." && pwd)
MANIFEST="$ROOT/adapter/claude-spt.toml"
RELDIR="$ROOT/tools/cc-spt-idle-translate/target/release"
EVENT='<EVENT type="msg" from="ci">translate-proof int probe</EVENT>'

command -v spt >/dev/null 2>&1 || { echo "SKIP: no spt on PATH"; exit 0; }

# --dir/--manifest landed in spt v0.13.2 — capability-detect rather than version-parse.
if ! spt adapter translate-proof --help 2>&1 | grep -q -- '--dir'; then
  echo "SKIP: spt 'adapter translate-proof' has no --dir/--manifest (needs v0.13.2). Binary itself: cargo tests green."
  exit 0
fi

BIN="$RELDIR/cc-spt-idle-translate"
[ -x "$BIN" ] || BIN="$RELDIR/cc-spt-idle-translate.exe"
[ -x "$BIN" ] || { echo "SKIP: binary not built (run sh ci/idle-translate/build.sh)"; exit 0; }

# Proof the dev binary in-place: --dir resolves the binary (before PATH) like the daemon, --manifest
# pins the bare-file gh_release manifest — no extracted install, no registry touch.
out=$(spt adapter translate-proof claude-spt --event "$EVENT" --manifest "$MANIFEST" --dir "$RELDIR" 2>&1)
rc=0
case "$out" in
  *TRANSLATE_PROOF_OK*) echo "ok  translate-proof: TRANSLATE_PROOF_OK" ;;
  *) echo "FAIL: translate-proof did not pass:"; printf '%s\n' "$out"; exit 1 ;;
esac
# The {commit} terminator is the F-016 fix — assert the proof saw it (no-commit would FAULT live).
case "$out" in
  *"commit: yes"*) echo "ok  emitted the mandatory {commit} terminator (commit: yes)" ;;
  *) echo "FAIL: proof reports no commit terminator (the F-016 fault condition):"; printf '%s\n' "$out"; rc=1 ;;
esac
# The submit is a discrete enter keypress (NOT a trailing \r in the text — a \r byte does not submit CC).
case "$out" in
  *enter*) echo "ok  emitted the discrete {key:enter} submit" ;;
  *) echo "FAIL: no {key:enter} submit in the command stream:"; printf '%s\n' "$out"; rc=1 ;;
esac
# ...and the text command must NOT carry a trailing \r anymore.
case "$out" in
  *'\r"'*) echo "FAIL: text still carries a trailing \\r (should be a discrete enter):"; printf '%s\n' "$out"; rc=1 ;;
  *) echo "ok  text command carries no trailing \\r" ;;
esac
# The envelope is FRAMED across multiple lines for visual distinction: a raw \n after the opening
# tag and before the closing </EVENT>. CC soft-newlines a bare \n (empirically gated 2026-06-24), so
# this renders as one user turn spanning lines, not an early submit. The emitted {text} carries the
# two deliberate framing newlines (JSON-escaped as \n in the command stream). [int->REQ-DIST-IDLE-MULTILINE]
case "$out" in
  *'>\n'*'\n</EVENT>'*) echo "ok  envelope framed across lines (\\n after opening tag and before </EVENT>)" ;;
  *) echo "FAIL: text is not framed across lines (multi-line envelope missing):"; printf '%s\n' "$out"; rc=1 ;;
esac

# ── CHECKPOINT branch (Feature 2) ────────────────────────────────────────────────────────────────
# An envelope carrying a `json="{"checkpoint":"v1",…}"` attr (what `spt send --json-payload` composes,
# v0.15.0) fires the clear+wake macro INSTEAD of normal delivery. This is the EMIT half of the binary's
# checkpoint branch — proven here by feeding translate-proof an envelope with the json attr directly
# (no --json-payload needed, so it runs on the pre-0.15.0 daemon). The send-side composition + the live
# self-send loopback stay the heavier real-claude int (deferred, like the multi-line APPLY half).
# [int->REQ-DIST-CHECKPOINT-COMMUNE]
CKPT='<EVENT type="msg" from="self" json="{"checkpoint":"v1","wake":"Resume now"}">checkpoint requested</EVENT>'
ck=$(spt adapter translate-proof claude-spt --event "$CKPT" --manifest "$MANIFEST" --dir "$RELDIR" 2>&1)
case "$ck" in
  *'text  "/clear"'*) echo "ok  checkpoint envelope fires the /clear macro" ;;
  *) echo "FAIL: checkpoint envelope did not emit /clear:"; printf '%s\n' "$ck"; rc=1 ;;
esac
case "$ck" in
  *'text  "Resume now"'*) echo "ok  checkpoint macro types the custom wake directive" ;;
  *) echo "FAIL: checkpoint macro missing the custom wake:"; printf '%s\n' "$ck"; rc=1 ;;
esac
case "$ck" in
  *'delay 500ms'*) echo "ok  checkpoint macro waits for /clear to settle (500ms)" ;;
  *) echo "FAIL: checkpoint macro missing the post-/clear settle:"; printf '%s\n' "$ck"; rc=1 ;;
esac
case "$ck" in
  *'commit: yes'*) echo "ok  checkpoint macro terminates with the mandatory {commit}" ;;
  *) echo "FAIL: checkpoint macro missing the {commit} terminator:"; printf '%s\n' "$ck"; rc=1 ;;
esac
# Default wake when the payload carries no `wake` field.
CKPT_DEF='<EVENT type="msg" from="self" json="{"checkpoint":"v1"}">checkpoint requested</EVENT>'
ckd=$(spt adapter translate-proof claude-spt --event "$CKPT_DEF" --manifest "$MANIFEST" --dir "$RELDIR" 2>&1)
case "$ckd" in
  *'text  "Proceed with next steps"'*) echo "ok  checkpoint without a wake uses the default" ;;
  *) echo "FAIL: default wake not applied:"; printf '%s\n' "$ckd"; rc=1 ;;
esac
# A normal (non-checkpoint) message must NOT fire the macro.
case "$out" in
  *'/clear'*) echo "FAIL: a normal message emitted /clear (checkpoint false-positive):"; printf '%s\n' "$out"; rc=1 ;;
  *) echo "ok  normal delivery never fires the /clear macro" ;;
esac

[ "$rc" -eq 0 ] && { echo "TRANSLATE-PROOF-INT OK"; exit 0; } || { echo "TRANSLATE-PROOF-INT FAIL"; exit 1; }

+RELDIR="$ROOT/tools/claude-spt/target/release"   # consolidated binary; manifest command = {adapter_dir}/claude-spt translate (D3)


BIN="$RELDIR/cc-spt-idle-translate"
[ -x "$BIN" ] || BIN="$RELDIR/cc-spt-idle-translate.exe"
[ -x "$BIN" ] || { echo "SKIP: binary not built (run sh ci/idle-translate/build.sh)"; exit 0; }

BIN="$RELDIR/claude-spt"
[ -x "$BIN" ] || BIN="$RELDIR/claude-spt.exe"
[ -x "$BIN" ] || { echo "SKIP: binary not built (run sh ci/digest/build.sh)"; exit 0; }


BIN="$RELDIR/claude-spt"
[ -x "$BIN" ] || BIN="$RELDIR/claude-spt.exe"
[ -x "$BIN" ] || { echo "SKIP: binary not built (run sh ci/digest/build.sh)"; exit 0; }

#!/bin/sh
# Integration proof: `spt adapter translate-proof` spawns + feeds the claude-spt
# [message-idle-translation-binary] (cc-spt-idle-translate) EXACTLY as the daemon does at idle
# delivery (init line then the --event envelope), reads back the emitted keystroke-command stream,
# and gates it. This is the EMIT half of REQ-DIST-IDLE-TRANSLATE — the cross-process author-time proof
# (ADR-0022, the EMIT-half mirror of digest-proof). It does NOT exercise the daemon's atomic PTY APPLY
# or controller buffering — that half stays the real-claude bringup int (deferred). [int->REQ-DIST-IDLE-TRANSLATE]
#
# Uses the v0.13.2 `--dir`/`--manifest` override (F-011 closed, W5) to proof the DEV binary straight
# from its build dir against the bare-file manifest: --dir resolves the binary (before PATH) exactly
# as the daemon does, --manifest pins the bare-file manifest. NO registry mutation — the prior form
# staged a disposable install dir and `adapter add`/`adapter remove claude-spt`, which soft-removed
# the REAL registered claude-spt on cleanup. Read-only now, so NO SPTC_ACCEPTANCE gate. Needs
# spt >= 0.13.2 (the --dir/--manifest options) + a built binary. Idempotent.
# Run: sh ci/idle-translate/translate-proof-int.sh   (exit 0 = pass).
#
# GREEN against the {commit}-terminated binary: emits the 6-command choreography ctrl+s · 50ms ·
# {text:"<envelope>"} · 50ms · {key:enter} · {commit:true}, exit 0 / TRANSLATE_PROOF_OK / "commit: yes".
# The submit is a discrete {key:enter} AFTER the text (a trailing \r byte does NOT submit a CC message,
# corrected 2026-06-23). The no-{commit} form FAULTs (the F-016 defect the proof's no-commit gate catches).
set -u
ROOT=$(CDPATH= cd "$(dirname "$0")/../.." && pwd)
MANIFEST="$ROOT/adapter/claude-spt.toml"
RELDIR="$ROOT/tools/claude-spt/target/release"   # consolidated binary; manifest command = {adapter_dir}/claude-spt translate (D3)
EVENT='<EVENT type="msg" from="ci">translate-proof int probe</EVENT>'

command -v spt >/dev/null 2>&1 || { echo "SKIP: no spt on PATH"; exit 0; }

# --dir/--manifest landed in spt v0.13.2 — capability-detect rather than version-parse.
if ! spt adapter translate-proof --help 2>&1 | grep -q -- '--dir'; then
  echo "SKIP: spt 'adapter translate-proof' has no --dir/--manifest (needs v0.13.2). Binary itself: cargo tests green."
  exit 0
fi

BIN="$RELDIR/cc-spt-idle-translate"
[ -x "$BIN" ] || BIN="$RELDIR/cc-spt-idle-translate.exe"
[ -x "$BIN" ] || { echo "SKIP: binary not built (run sh ci/idle-translate/build.sh)"; exit 0; }

# Proof the dev binary in-place: --dir resolves the binary (before PATH) like the daemon, --manifest
# pins the bare-file gh_release manifest — no extracted install, no registry touch.
out=$(spt adapter translate-proof claude-spt --event "$EVENT" --manifest "$MANIFEST" --dir "$RELDIR" 2>&1)
rc=0
case "$out" in
  *TRANSLATE_PROOF_OK*) echo "ok  translate-proof: TRANSLATE_PROOF_OK" ;;
  *) echo "FAIL: translate-proof did not pass:"; printf '%s\n' "$out"; exit 1 ;;
esac
# The {commit} terminator is the F-016 fix — assert the proof saw it (no-commit would FAULT live).
case "$out" in
  *"commit: yes"*) echo "ok  emitted the mandatory {commit} terminator (commit: yes)" ;;
  *) echo "FAIL: proof reports no commit terminator (the F-016 fault condition):"; printf '%s\n' "$out"; rc=1 ;;
esac
# The submit is a discrete enter keypress (NOT a trailing \r in the text — a \r byte does not submit CC).
case "$out" in
  *enter*) echo "ok  emitted the discrete {key:enter} submit" ;;
  *) echo "FAIL: no {key:enter} submit in the command stream:"; printf '%s\n' "$out"; rc=1 ;;
esac
# ...and the text command must NOT carry a trailing \r anymore.
case "$out" in
  *'\r"'*) echo "FAIL: text still carries a trailing \\r (should be a discrete enter):"; printf '%s\n' "$out"; rc=1 ;;
  *) echo "ok  text command carries no trailing \\r" ;;
esac
# The envelope is FRAMED across multiple lines for visual distinction: a raw \n after the opening
# tag and before the closing </EVENT>. CC soft-newlines a bare \n (empirically gated 2026-06-24), so
# this renders as one user turn spanning lines, not an early submit. The emitted {text} carries the
# two deliberate framing newlines (JSON-escaped as \n in the command stream). [int->REQ-DIST-IDLE-MULTILINE]
case "$out" in
  *'>\n'*'\n</EVENT>'*) echo "ok  envelope framed across lines (\\n after opening tag and before </EVENT>)" ;;
  *) echo "FAIL: text is not framed across lines (multi-line envelope missing):"; printf '%s\n' "$out"; rc=1 ;;
esac

# ── CHECKPOINT branch (Feature 2) ────────────────────────────────────────────────────────────────
# An envelope carrying a `json="{"checkpoint":"v1",…}"` attr (what `spt send --json-payload` composes,
# v0.15.0) fires the clear+wake macro INSTEAD of normal delivery. This is the EMIT half of the binary's
# checkpoint branch — proven here by feeding translate-proof an envelope with the json attr directly
# (no --json-payload needed, so it runs on the pre-0.15.0 daemon). The send-side composition + the live
# self-send loopback stay the heavier real-claude int (deferred, like the multi-line APPLY half).
# [int->REQ-DIST-CHECKPOINT-COMMUNE]
CKPT='<EVENT type="msg" from="self" json="{"checkpoint":"v1","wake":"Resume now"}">checkpoint requested</EVENT>'
ck=$(spt adapter translate-proof claude-spt --event "$CKPT" --manifest "$MANIFEST" --dir "$RELDIR" 2>&1)
case "$ck" in
  *'text  "/clear"'*) echo "ok  checkpoint envelope fires the /clear macro" ;;
  *) echo "FAIL: checkpoint envelope did not emit /clear:"; printf '%s\n' "$ck"; rc=1 ;;
esac
case "$ck" in
  *'text  "Resume now"'*) echo "ok  checkpoint macro types the custom wake directive" ;;
  *) echo "FAIL: checkpoint macro missing the custom wake:"; printf '%s\n' "$ck"; rc=1 ;;
esac
case "$ck" in
  *'delay 500ms'*) echo "ok  checkpoint macro waits for /clear to settle (500ms)" ;;
  *) echo "FAIL: checkpoint macro missing the post-/clear settle:"; printf '%s\n' "$ck"; rc=1 ;;
esac
case "$ck" in
  *'commit: yes'*) echo "ok  checkpoint macro terminates with the mandatory {commit}" ;;
  *) echo "FAIL: checkpoint macro missing the {commit} terminator:"; printf '%s\n' "$ck"; rc=1 ;;
esac
# Default wake when the payload carries no `wake` field.
CKPT_DEF='<EVENT type="msg" from="self" json="{"checkpoint":"v1"}">checkpoint requested</EVENT>'
ckd=$(spt adapter translate-proof claude-spt --event "$CKPT_DEF" --manifest "$MANIFEST" --dir "$RELDIR" 2>&1)
case "$ckd" in
  *'text  "Proceed with next steps"'*) echo "ok  checkpoint without a wake uses the default" ;;
  *) echo "FAIL: default wake not applied:"; printf '%s\n' "$ckd"; rc=1 ;;
esac
# A normal (non-checkpoint) message must NOT fire the macro.
case "$out" in
  *'/clear'*) echo "FAIL: a normal message emitted /clear (checkpoint false-positive):"; printf '%s\n' "$out"; rc=1 ;;
  *) echo "ok  normal delivery never fires the /clear macro" ;;
esac

[ "$rc" -eq 0 ] && { echo "TRANSLATE-PROOF-INT OK"; exit 0; } || { echo "TRANSLATE-PROOF-INT FAIL"; exit 1; }

-[ -x "$BIN" ] || BIN="$RELDIR/cc-spt-idle-translate.exe"

-[ -x "$BIN" ] || { echo "SKIP: binary not built (run sh ci/idle-translate/build.sh)"; exit 0; }

+[ -x "$BIN" ] || BIN="$RELDIR/claude-spt.exe"

+[ -x "$BIN" ] || { echo "SKIP: binary not built (run sh ci/digest/build.sh)"; exit 0; }


# Integration proof: `spt adapter translate-proof` spawns + feeds the claude-spt
# [message-idle-translation-binary] (cc-spt-idle-translate) EXACTLY as the daemon does at idle

# Integration proof: `spt adapter translate-proof` spawns + feeds the claude-spt
# [message-idle-translation-binary] (`claude-spt translate` subcommand) EXACTLY as the daemon does at idle


# Integration proof: `spt adapter translate-proof` spawns + feeds the claude-spt
# [message-idle-translation-binary] (`claude-spt translate` subcommand) EXACTLY as the daemon does at idle

#!/bin/sh
# Integration proof: `spt adapter translate-proof` spawns + feeds the claude-spt
# [message-idle-translation-binary] (cc-spt-idle-translate) EXACTLY as the daemon does at idle
# delivery (init line then the --event envelope), reads back the emitted keystroke-command stream,
# and gates it. This is the EMIT half of REQ-DIST-IDLE-TRANSLATE — the cross-process author-time proof
# (ADR-0022, the EMIT-half mirror of digest-proof). It does NOT exercise the daemon's atomic PTY APPLY
# or controller buffering — that half stays the real-claude bringup int (deferred). [int->REQ-DIST-IDLE-TRANSLATE]
#
# Uses the v0.13.2 `--dir`/`--manifest` override (F-011 closed, W5) to proof the DEV binary straight
# from its build dir against the bare-file manifest: --dir resolves the binary (before PATH) exactly
# as the daemon does, --manifest pins the bare-file manifest. NO registry mutation — the prior form
# staged a disposable install dir and `adapter add`/`adapter remove claude-spt`, which soft-removed
# the REAL registered claude-spt on cleanup. Read-only now, so NO SPTC_ACCEPTANCE gate. Needs
# spt >= 0.13.2 (the --dir/--manifest options) + a built binary. Idempotent.
# Run: sh ci/idle-translate/translate-proof-int.sh   (exit 0 = pass).
#
# GREEN against the {commit}-terminated binary: emits the 6-command choreography ctrl+s · 50ms ·
# {text:"<envelope>"} · 50ms · {key:enter} · {commit:true}, exit 0 / TRANSLATE_PROOF_OK / "commit: yes".
# The submit is a discrete {key:enter} AFTER the text (a trailing \r byte does NOT submit a CC message,
# corrected 2026-06-23). The no-{commit} form FAULTs (the F-016 defect the proof's no-commit gate catches).
set -u
ROOT=$(CDPATH= cd "$(dirname "$0")/../.." && pwd)
MANIFEST="$ROOT/adapter/claude-spt.toml"
RELDIR="$ROOT/tools/claude-spt/target/release"   # consolidated binary; manifest command = {adapter_dir}/claude-spt translate (D3)
EVENT='<EVENT type="msg" from="ci">translate-proof int probe</EVENT>'

command -v spt >/dev/null 2>&1 || { echo "SKIP: no spt on PATH"; exit 0; }

# --dir/--manifest landed in spt v0.13.2 — capability-detect rather than version-parse.
if ! spt adapter translate-proof --help 2>&1 | grep -q -- '--dir'; then
  echo "SKIP: spt 'adapter translate-proof' has no --dir/--manifest (needs v0.13.2). Binary itself: cargo tests green."
  exit 0
fi

BIN="$RELDIR/claude-spt"
[ -x "$BIN" ] || BIN="$RELDIR/claude-spt.exe"
[ -x "$BIN" ] || { echo "SKIP: binary not built (run sh ci/digest/build.sh)"; exit 0; }

# Proof the dev binary in-place: --dir resolves the binary (before PATH) like the daemon, --manifest
# pins the bare-file gh_release manifest — no extracted install, no registry touch.
out=$(spt adapter translate-proof claude-spt --event "$EVENT" --manifest "$MANIFEST" --dir "$RELDIR" 2>&1)
rc=0
case "$out" in
  *TRANSLATE_PROOF_OK*) echo "ok  translate-proof: TRANSLATE_PROOF_OK" ;;
  *) echo "FAIL: translate-proof did not pass:"; printf '%s\n' "$out"; exit 1 ;;
esac
# The {commit} terminator is the F-016 fix — assert the proof saw it (no-commit would FAULT live).
case "$out" in
  *"commit: yes"*) echo "ok  emitted the mandatory {commit} terminator (commit: yes)" ;;
  *) echo "FAIL: proof reports no commit terminator (the F-016 fault condition):"; printf '%s\n' "$out"; rc=1 ;;
esac
# The submit is a discrete enter keypress (NOT a trailing \r in the text — a \r byte does not submit CC).
case "$out" in
  *enter*) echo "ok  emitted the discrete {key:enter} submit" ;;
  *) echo "FAIL: no {key:enter} submit in the command stream:"; printf '%s\n' "$out"; rc=1 ;;
esac
# ...and the text command must NOT carry a trailing \r anymore.
case "$out" in
  *'\r"'*) echo "FAIL: text still carries a trailing \\r (should be a discrete enter):"; printf '%s\n' "$out"; rc=1 ;;
  *) echo "ok  text command carries no trailing \\r" ;;
esac
# The envelope is FRAMED across multiple lines for visual distinction: a raw \n after the opening
# tag and before the closing </EVENT>. CC soft-newlines a bare \n (empirically gated 2026-06-24), so
# this renders as one user turn spanning lines, not an early submit. The emitted {text} carries the
# two deliberate framing newlines (JSON-escaped as \n in the command stream). [int->REQ-DIST-IDLE-MULTILINE]
case "$out" in
  *'>\n'*'\n</EVENT>'*) echo "ok  envelope framed across lines (\\n after opening tag and before </EVENT>)" ;;
  *) echo "FAIL: text is not framed across lines (multi-line envelope missing):"; printf '%s\n' "$out"; rc=1 ;;
esac

# ── CHECKPOINT branch (Feature 2) ────────────────────────────────────────────────────────────────
# An envelope carrying a `json="{"checkpoint":"v1",…}"` attr (what `spt send --json-payload` composes,
# v0.15.0) fires the clear+wake macro INSTEAD of normal delivery. This is the EMIT half of the binary's
# checkpoint branch — proven here by feeding translate-proof an envelope with the json attr directly
# (no --json-payload needed, so it runs on the pre-0.15.0 daemon). The send-side composition + the live
# self-send loopback stay the heavier real-claude int (deferred, like the multi-line APPLY half).
# [int->REQ-DIST-CHECKPOINT-COMMUNE]
CKPT='<EVENT type="msg" from="self" json="{"checkpoint":"v1","wake":"Resume now"}">checkpoint requested</EVENT>'
ck=$(spt adapter translate-proof claude-spt --event "$CKPT" --manifest "$MANIFEST" --dir "$RELDIR" 2>&1)
case "$ck" in
  *'text  "/clear"'*) echo "ok  checkpoint envelope fires the /clear macro" ;;
  *) echo "FAIL: checkpoint envelope did not emit /clear:"; printf '%s\n' "$ck"; rc=1 ;;
esac
case "$ck" in
  *'text  "Resume now"'*) echo "ok  checkpoint macro types the custom wake directive" ;;
  *) echo "FAIL: checkpoint macro missing the custom wake:"; printf '%s\n' "$ck"; rc=1 ;;
esac
case "$ck" in
  *'delay 500ms'*) echo "ok  checkpoint macro waits for /clear to settle (500ms)" ;;
  *) echo "FAIL: checkpoint macro missing the post-/clear settle:"; printf '%s\n' "$ck"; rc=1 ;;
esac
case "$ck" in
  *'commit: yes'*) echo "ok  checkpoint macro terminates with the mandatory {commit}" ;;
  *) echo "FAIL: checkpoint macro missing the {commit} terminator:"; printf '%s\n' "$ck"; rc=1 ;;
esac
# Default wake when the payload carries no `wake` field.
CKPT_DEF='<EVENT type="msg" from="self" json="{"checkpoint":"v1"}">checkpoint requested</EVENT>'
ckd=$(spt adapter translate-proof claude-spt --event "$CKPT_DEF" --manifest "$MANIFEST" --dir "$RELDIR" 2>&1)
case "$ckd" in
  *'text  "Proceed with next steps"'*) echo "ok  checkpoint without a wake uses the default" ;;
  *) echo "FAIL: default wake not applied:"; printf '%s\n' "$ckd"; rc=1 ;;
esac
# A normal (non-checkpoint) message must NOT fire the macro.
case "$out" in
  *'/clear'*) echo "FAIL: a normal message emitted /clear (checkpoint false-positive):"; printf '%s\n' "$out"; rc=1 ;;
  *) echo "ok  normal delivery never fires the /clear macro" ;;
esac

[ "$rc" -eq 0 ] && { echo "TRANSLATE-PROOF-INT OK"; exit 0; } || { echo "TRANSLATE-PROOF-INT FAIL"; exit 1; }

-# [message-idle-translation-binary] (cc-spt-idle-translate) EXACTLY as the daemon does at idle

+# [message-idle-translation-binary] (`claude-spt translate` subcommand) EXACTLY as the daemon does at idle


`ci/idle-translate/build.sh` → shim (crate gone, built by the consolidated build). Read it:


1	#!/bin/sh
2	# Build + unit-test the cc-spt-idle-translate crate (Rust) — the [message-idle-translation-binary]
3	# for the claude-spt adapter. The crate's cargo tests ARE the binary's unit coverage (the 5-step
4	# keystroke choreography, the trailing-\r submit, CR/LF neutralization, init/input/unknown-type
5	# no-output, forward-compat unknown fields, single-field command shape). SKIP if cargo is absent so a
6	# host without the Rust toolchain announces it rather than silently passing (no silent caps).
7	# [impl->REQ-DIST-IDLE-TRANSLATE]
8	set -u
9	ROOT=$(CDPATH= cd "$(dirname "$0")/../.." && pwd)
10	CRATE="$ROOT/tools/cc-spt-idle-translate/Cargo.toml"
11
12	if ! command -v cargo >/dev/null 2>&1; then
13	  echo "SKIP: cargo not on PATH (Rust toolchain needed to build/test cc-spt-idle-translate)"
14	  exit 0
15	fi
16
17	cargo test  --quiet --manifest-path "$CRATE" || { echo "FAIL: cargo test"; exit 1; }
18	cargo build --release --quiet --manifest-path "$CRATE" || { echo "FAIL: cargo build --release"; exit 1; }
19	echo "ok  cc-spt-idle-translate: cargo test + release build"
20

#!/bin/sh
# Build + unit-test the cc-spt-idle-translate crate (Rust) — the [message-idle-translation-binary]
# for the claude-spt adapter. The crate's cargo tests ARE the binary's unit coverage (the 5-step
# keystroke choreography, the trailing-\r submit, CR/LF neutralization, init/input/unknown-type
# no-output, forward-compat unknown fields, single-field command shape). SKIP if cargo is absent so a
# host without the Rust toolchain announces it rather than silently passing (no silent caps).
# [impl->REQ-DIST-IDLE-TRANSLATE]
set -u
ROOT=$(CDPATH= cd "$(dirname "$0")/../.." && pwd)
CRATE="$ROOT/tools/cc-spt-idle-translate/Cargo.toml"

if ! command -v cargo >/dev/null 2>&1; then
  echo "SKIP: cargo not on PATH (Rust toolchain needed to build/test cc-spt-idle-translate)"
  exit 0
fi

cargo test  --quiet --manifest-path "$CRATE" || { echo "FAIL: cargo test"; exit 1; }
cargo build --release --quiet --manifest-path "$CRATE" || { echo "FAIL: cargo build --release"; exit 1; }
echo "ok  cc-spt-idle-translate: cargo test + release build"


Write

#!/bin/sh
# The idle-translation filter is now the `translate` subcommand of the CONSOLIDATED claude-spt crate
# (D3 fold, ADR-0006 — spt-core v0.16.0 gave [message-idle-translation-binary] a `command` field, so
# the standalone cc-spt-idle-translate crate is retired). It is built + unit-tested by ci/digest/build.sh
# (the canonical build of the one binary; its cargo run covers the translate choreography/checkpoint
# tests too). This gate is a thin shim so the run-gates "idle-translate" slot stays announced WITHOUT a
# redundant second cargo build/test of the same crate. The daemon spawn-on-up/reap-on-down lifecycle +
# atomic PTY apply stay covered by ci/idle-translate/translate-proof-int.sh (the int).
# [impl->REQ-DIST-IDLE-TRANSLATE]
set -u
echo "ok  cc-spt-idle-translate: folded into the consolidated claude-spt crate as `translate` (built/tested by ci/digest/build.sh)"


The file C:\Users\decid\Documents\projects\spt-claude-code\ci\idle-translate\build.sh has been updated successfully. (file state is current in your context — no need to Read it back)

#!/bin/sh
# The idle-translation filter is now the `translate` subcommand of the CONSOLIDATED claude-spt crate
# (D3 fold, ADR-0006 — spt-core v0.16.0 gave [message-idle-translation-binary] a `command` field, so
# the standalone cc-spt-idle-translate crate is retired). It is built + unit-tested by ci/digest/build.sh
# (the canonical build of the one binary; its cargo run covers the translate choreography/checkpoint
# tests too). This gate is a thin shim so the run-gates "idle-translate" slot stays announced WITHOUT a
# redundant second cargo build/test of the same crate. The daemon spawn-on-up/reap-on-down lifecycle +
# atomic PTY apply stay covered by ci/idle-translate/translate-proof-int.sh (the int).
# [impl->REQ-DIST-IDLE-TRANSLATE]
set -u
echo "ok  cc-spt-idle-translate: folded into the consolidated claude-spt crate as `translate` (built/tested by ci/digest/build.sh)"

-# Build + unit-test the cc-spt-idle-translate crate (Rust) — the [message-idle-translation-binary]

-# for the claude-spt adapter. The crate's cargo tests ARE the binary's unit coverage (the 5-step

-# keystroke choreography, the trailing-\r submit, CR/LF neutralization, init/input/unknown-type

-# no-output, forward-compat unknown fields, single-field command shape). SKIP if cargo is absent so a

-# host without the Rust toolchain announces it rather than silently passing (no silent caps).

+# The idle-translation filter is now the `translate` subcommand of the CONSOLIDATED claude-spt crate

+# (D3 fold, ADR-0006 — spt-core v0.16.0 gave [message-idle-translation-binary] a `command` field, so

+# the standalone cc-spt-idle-translate crate is retired). It is built + unit-tested by ci/digest/build.sh

+# (the canonical build of the one binary; its cargo run covers the translate choreography/checkpoint

+# tests too). This gate is a thin shim so the run-gates "idle-translate" slot stays announced WITHOUT a

+# redundant second cargo build/test of the same crate. The daemon spawn-on-up/reap-on-down lifecycle +

+# atomic PTY apply stay covered by ci/idle-translate/translate-proof-int.sh (the int).

-if ! command -v cargo >/dev/null 2>&1; then

-  echo "SKIP: cargo not on PATH (Rust toolchain needed to build/test cc-spt-idle-translate)"

-cargo test  --quiet --manifest-path "$CRATE" || { echo "FAIL: cargo test"; exit 1; }

-cargo build --release --quiet --manifest-path "$CRATE" || { echo "FAIL: cargo build --release"; exit 1; }

-echo "ok  cc-spt-idle-translate: cargo test + release build"

+echo "ok  cc-spt-idle-translate: folded into the consolidated claude-spt crate as `translate` (built/tested by ci/digest/build.sh)"

#!/bin/sh
# Build + unit-test the cc-spt-idle-translate crate (Rust) — the [message-idle-translation-binary]
# for the claude-spt adapter. The crate's cargo tests ARE the binary's unit coverage (the 5-step
# keystroke choreography, the trailing-\r submit, CR/LF neutralization, init/input/unknown-type
# no-output, forward-compat unknown fields, single-field command shape). SKIP if cargo is absent so a
# host without the Rust toolchain announces it rather than silently passing (no silent caps).
# [impl->REQ-DIST-IDLE-TRANSLATE]
set -u
ROOT=$(CDPATH= cd "$(dirname "$0")/../.." && pwd)
CRATE="$ROOT/tools/cc-spt-idle-translate/Cargo.toml"

if ! command -v cargo >/dev/null 2>&1; then
  echo "SKIP: cargo not on PATH (Rust toolchain needed to build/test cc-spt-idle-translate)"
  exit 0
fi

cargo test  --quiet --manifest-path "$CRATE" || { echo "FAIL: cargo test"; exit 1; }
cargo build --release --quiet --manifest-path "$CRATE" || { echo "FAIL: cargo build --release"; exit 1; }
echo "ok  cc-spt-idle-translate: cargo test + release build"


Bug — backticks in that echo would command-substitute. Fix:


Edit

echo "ok  cc-spt-idle-translate: folded into the consolidated claude-spt crate as `translate` (built/tested by ci/digest/build.sh)"

echo "ok  cc-spt-idle-translate: folded into the consolidated claude-spt crate as the translate subcommand (built/tested by ci/digest/build.sh)"


#!/bin/sh
# The idle-translation filter is now the `translate` subcommand of the CONSOLIDATED claude-spt crate
# (D3 fold, ADR-0006 — spt-core v0.16.0 gave [message-idle-translation-binary] a `command` field, so
# the standalone cc-spt-idle-translate crate is retired). It is built + unit-tested by ci/digest/build.sh
# (the canonical build of the one binary; its cargo run covers the translate choreography/checkpoint
# tests too). This gate is a thin shim so the run-gates "idle-translate" slot stays announced WITHOUT a
# redundant second cargo build/test of the same crate. The daemon spawn-on-up/reap-on-down lifecycle +
# atomic PTY apply stay covered by ci/idle-translate/translate-proof-int.sh (the int).
# [impl->REQ-DIST-IDLE-TRANSLATE]
set -u
echo "ok  cc-spt-idle-translate: folded into the consolidated claude-spt crate as `translate` (built/tested by ci/digest/build.sh)"

-echo "ok  cc-spt-idle-translate: folded into the consolidated claude-spt crate as `translate` (built/tested by ci/digest/build.sh)"

+echo "ok  cc-spt-idle-translate: folded into the consolidated claude-spt crate as the translate subcommand (built/tested by ci/digest/build.sh)"


Now package-adapter.sh → single binary, and the archive test + manifest-shortcut assertion:


#   ├── x86_64-pc-windows-msvc/       ← this triple's binaries, mirroring the flat-root tree
#   │   ├── claude-spt.exe · cc-spt-idle-translate.exe
#   └── x86_64-unknown-linux-gnu/
#       └── claude-spt · cc-spt-idle-translate

#   ├── x86_64-pc-windows-msvc/       ← this triple's binary, mirroring the flat-root tree
#   │   ├── claude-spt.exe
#   └── x86_64-unknown-linux-gnu/
#       └── claude-spt


The file C:\Users\decid\Documents\projects\spt-claude-code\ci\publish\package-adapter.sh has been updated successfully. (file state is current in your context — no need to Read it back)

#   ├── x86_64-pc-windows-msvc/       ← this triple's binary, mirroring the flat-root tree
#   │   ├── claude-spt.exe
#   └── x86_64-unknown-linux-gnu/
#       └── claude-spt

#!/bin/sh
# Pack the SINGLE MULTI-PLATFORM `adapter.spt` release asset — the end-user distribution acquired by
# `spt adapter add --release SaberMage/claude-spt` and carried by the `[update] gh_release`
# avenue. ONE fat archive covers every supported platform (ADR-0024 W1, spt-core >= 0.13.2).
# The release/install/update repo is `SaberMage/claude-spt` (renamed from spt-claude-code, U3/ADR-0005).
# [impl->REQ-DIST-ADAPTER-RELEASE] [impl->REQ-DIST-ADAPTER-PEROS] [impl->REQ-DIST-NAME-UNIFY]
#
#   adapter.spt (tar.gz)
#   ├── manifest.toml                 ← SHARED, at archive root (renamed from claude-spt.toml)
#   ├── strings/                      ← SHARED, at archive root
#   ├── x86_64-pc-windows-msvc/       ← this triple's binaries, mirroring the flat-root tree
#   │   ├── claude-spt.exe · cc-spt-idle-translate.exe
#   └── x86_64-unknown-linux-gnu/
#       └── claude-spt · cc-spt-idle-translate
#
# spt-core CLASSIFIES on top-level entry names: a known target-triple dir ⇒ multi-platform; it places
# the shared-root entries + FLATTENS this node's <triple>/* into the install dir (so a bare-name
# command token still resolves at <install_dir>/<program>, REQ-INSTALL-11). This RETIRES the F-014
# per-OS stopgap (the old single-OS `adapter.spt`-by-default that broke on a foreign host).
#
# CONSTRAINTS (doyle, extract_release_archive): only x86_64-pc-windows-msvc + x86_64-unknown-linux-gnu
# are recognized triples — an UNRECOGNIZED dir name is silently treated as a shared-root entry and
# lands flat (no error), so for platforms beyond these two ship a separate single-triple asset via
# `--asset` instead of adding a dir here. A fat archive REQUIRES the manifest's
# min_spt_core_version >= 0.13.2 (older spt-core cannot read it). DRY-RUN by default; never uploads.
#
# Usage:
#   package-adapter.sh            # dry-run: validate + print the archive plan, write nothing
#   package-adapter.sh --apply    # write dist/adapter.spt (validated)
set -u
HERE=$(CDPATH= cd "$(dirname "$0")" && pwd)
ROOT=$(CDPATH= cd "$HERE/../.." && pwd)
ADAPTER="$ROOT/adapter"
MANIFEST="$ADAPTER/claude-spt.toml"   # renamed to manifest.toml INSIDE the archive (root-only rule)
STRINGS="$ADAPTER/strings"
TOOLS="$ROOT/tools"
OUT="${ADAPTER_SPT_OUT:-$ROOT/dist/adapter.spt}"   # overridable so the unit test writes to a tmp file
APPLY=0
[ "${1:-}" = "--apply" ] && APPLY=1

# The recognized triples and where each platform's release binaries live. A native Windows build lands
# in target/release; the Linux build is cross-compiled (cargo-zigbuild) into
# target/x86_64-unknown-linux-gnu/release. Override SPTC_WIN_RELSUB / SPTC_LINUX_RELSUB for a
# non-default layout. The two tool binaries each live in their own crate under tools/ (the former
# claude-spt-digest + claude-spt-psyche crates are now the digest/psyche subcommands of claude-spt;
# cc-spt-idle-translate stays separate until doyle ask #3 lets it fold in too — ADR-0006/U2).
WIN_TRIPLE=x86_64-pc-windows-msvc
LINUX_TRIPLE=x86_64-unknown-linux-gnu
WIN_RELSUB="${SPTC_WIN_RELSUB:-release}"
LINUX_RELSUB="${SPTC_LINUX_RELSUB:-$LINUX_TRIPLE/release}"
BINS="claude-spt cc-spt-idle-translate"

# Validate the manifest first — refuse to ship an invalid adapter.
echo "== validate manifest =="
if ! sh "$ROOT/ci/manifest/check-manifest.sh"; then
  echo "REFUSING to package: manifest failed schema validation (fix above, then retry)." >&2
  exit 1
fi

# A fat archive is only readable on spt-core >= 0.13.2 — the manifest MUST declare that floor.
floor=$(grep -E '^min_spt_core_version' "$MANIFEST" | head -1 | sed -E 's/.*"([^"]+)".*/\1/')
case "$floor" in
  0.13.2|0.13.[3-9]*|0.1[4-9].*|0.[2-9][0-9].*|[1-9].*) : ;;
  *) echo "REFUSING to package: min_spt_core_version is '$floor' but a multi-platform fat .spt needs >= 0.13.2 (doyle)." >&2; exit 1 ;;
esac

# Require BOTH platforms' release binaries (NOT auto-copied by register — they ride the archive).
echo
echo "== tool binaries =="
missing=0
win_path()   { echo "$TOOLS/$1/target/$WIN_RELSUB/$1.exe"; }
linux_path() { echo "$TOOLS/$1/target/$LINUX_RELSUB/$1"; }
for b in $BINS; do
  for p in "$(win_path "$b")" "$(linux_path "$b")"; do
    if [ -f "$p" ]; then echo "  ok    $p"; else echo "  MISS  $p"; missing=1; fi
  done
done
if [ "$missing" -ne 0 ]; then
  echo "REFUSING to package: build BOTH platforms first — Windows: sh ci/{digest,idle-translate}/build.sh;" >&2
  echo "Linux (cross): cargo-zigbuild --release --target $LINUX_TRIPLE for each tool crate (see docs/RELEASE-RUNBOOK.md)." >&2
  exit 1
fi

echo
echo "== plan ($([ "$APPLY" -eq 1 ] && echo APPLY || echo DRY-RUN)) =="
echo "manifest : $MANIFEST  ->  (root) manifest.toml          [min_spt_core $floor]"
echo "strings  : $STRINGS/  ->  (root) strings/"
echo "win bins : -> $WIN_TRIPLE/{$(echo $BINS | tr ' ' ',')}.exe"
echo "linux bins: -> $LINUX_TRIPLE/{$(echo $BINS | tr ' ' ',')}"
echo "asset    : $OUT  (the single fat adapter.spt — auto-resolves the host triple on install)"

if [ "$APPLY" -ne 1 ]; then
  echo
  echo "DRY-RUN: nothing written. Re-run with --apply to write $OUT, then attach it as 'adapter.spt'"
  echo "to a GitHub release on the monorepo. End users: 'spt adapter add --release SaberMage/claude-spt'"
  echo "(default asset adapter.spt). Needs spt v0.13.2+. See docs/RELEASE-RUNBOOK.md."
  exit 0
fi

# Stage the archive ROOT in a temp dir, then tar from it so paths are root-relative (no leading dirs).
STAGE=$(mktemp -d "${TMPDIR:-/tmp}/sptc-adapter.XXXXXX") || { echo "FATAL: mktemp failed"; exit 2; }
trap 'rm -rf "$STAGE"' EXIT
cp "$MANIFEST" "$STAGE/manifest.toml"
cp -r "$STRINGS" "$STAGE/strings"
mkdir -p "$STAGE/$WIN_TRIPLE" "$STAGE/$LINUX_TRIPLE"
for b in $BINS; do
  cp "$(win_path "$b")"   "$STAGE/$WIN_TRIPLE/$b.exe"
  cp "$(linux_path "$b")" "$STAGE/$LINUX_TRIPLE/$b"
done

mkdir -p "$(dirname "$OUT")" || { echo "FATAL: cannot create $(dirname "$OUT")"; exit 2; }
( cd "$STAGE" && tar -czf "$OUT" manifest.toml strings "$WIN_TRIPLE" "$LINUX_TRIPLE" ) \
  || { echo "FATAL: tar failed"; exit 2; }

# Self-validate the produced archive: shared manifest at ROOT + BOTH triple dirs present.
echo
echo "== validate archive =="
listing=$(tar -tzf "$OUT")
fatal=0
echo "$listing" | grep -qx "manifest.toml" || { echo "FATAL: manifest.toml not at archive root" >&2; fatal=1; }
echo "$listing" | grep -q "^$WIN_TRIPLE/claude-spt.exe$" || { echo "FATAL: missing $WIN_TRIPLE/ binaries" >&2; fatal=1; }
echo "$listing" | grep -q "^$LINUX_TRIPLE/claude-spt$"   || { echo "FATAL: missing $LINUX_TRIPLE/ binaries" >&2; fatal=1; }
# Guard the footgun: no UNRECOGNIZED top-level dir (would silently flatten as a shared-root entry).
badtop=$(echo "$listing" | awk -F/ 'NF>1 {print $1}' | sort -u | grep -vE "^(strings|$WIN_TRIPLE|$LINUX_TRIPLE)$" || true)
[ -n "$badtop" ] && { echo "FATAL: unrecognized top-level dir(s) [$badtop] — spt-core would mis-place them flat" >&2; fatal=1; }
[ "$fatal" -eq 0 ] || exit 1
echo "ok   manifest.toml + strings/ at root; $WIN_TRIPLE/ + $LINUX_TRIPLE/ binaries present; no stray top-level dirs"

echo
echo "WROTE $OUT (single fat adapter.spt). Next (operator): attach as 'adapter.spt' to a GitHub release"
echo "on SaberMage/claude-spt; end users 'spt adapter add --release SaberMage/claude-spt' (spt"
echo "v0.13.2+). The fat archive auto-resolves the host's binaries — no per-OS --asset needed."
exit 0

-#   ├── x86_64-pc-windows-msvc/       ← this triple's binaries, mirroring the flat-root tree

-#   │   ├── claude-spt.exe · cc-spt-idle-translate.exe

+#   ├── x86_64-pc-windows-msvc/       ← this triple's binary, mirroring the flat-root tree

-#       └── claude-spt · cc-spt-idle-translate


# non-default layout. The two tool binaries each live in their own crate under tools/ (the former
# claude-spt-digest + claude-spt-psyche crates are now the digest/psyche subcommands of claude-spt;
# cc-spt-idle-translate stays separate until doyle ask #3 lets it fold in too — ADR-0006/U2).
WIN_TRIPLE=x86_64-pc-windows-msvc
LINUX_TRIPLE=x86_64-unknown-linux-gnu
WIN_RELSUB="${SPTC_WIN_RELSUB:-release}"
LINUX_RELSUB="${SPTC_LINUX_RELSUB:-$LINUX_TRIPLE/release}"
BINS="claude-spt cc-spt-idle-translate"

# non-default layout. There is now ONE tool binary: the consolidated claude-spt crate carries all four
# subcommands — digest / psyche / post-update / translate (the last folded in at the v0.8.0 cut once
# spt-core v0.16.0 gave [message-idle-translation-binary] a `command` field — ADR-0006/D3).
WIN_TRIPLE=x86_64-pc-windows-msvc
LINUX_TRIPLE=x86_64-unknown-linux-gnu
WIN_RELSUB="${SPTC_WIN_RELSUB:-release}"
LINUX_RELSUB="${SPTC_LINUX_RELSUB:-$LINUX_TRIPLE/release}"
BINS="claude-spt"


# non-default layout. There is now ONE tool binary: the consolidated claude-spt crate carries all four
# subcommands — digest / psyche / post-update / translate (the last folded in at the v0.8.0 cut once
# spt-core v0.16.0 gave [message-idle-translation-binary] a `command` field — ADR-0006/D3).
WIN_TRIPLE=x86_64-pc-windows-msvc
LINUX_TRIPLE=x86_64-unknown-linux-gnu
WIN_RELSUB="${SPTC_WIN_RELSUB:-release}"
LINUX_RELSUB="${SPTC_LINUX_RELSUB:-$LINUX_TRIPLE/release}"
BINS="claude-spt"

#!/bin/sh
# Pack the SINGLE MULTI-PLATFORM `adapter.spt` release asset — the end-user distribution acquired by
# `spt adapter add --release SaberMage/claude-spt` and carried by the `[update] gh_release`
# avenue. ONE fat archive covers every supported platform (ADR-0024 W1, spt-core >= 0.13.2).
# The release/install/update repo is `SaberMage/claude-spt` (renamed from spt-claude-code, U3/ADR-0005).
# [impl->REQ-DIST-ADAPTER-RELEASE] [impl->REQ-DIST-ADAPTER-PEROS] [impl->REQ-DIST-NAME-UNIFY]
#
#   adapter.spt (tar.gz)
#   ├── manifest.toml                 ← SHARED, at archive root (renamed from claude-spt.toml)
#   ├── strings/                      ← SHARED, at archive root
#   ├── x86_64-pc-windows-msvc/       ← this triple's binary, mirroring the flat-root tree
#   │   ├── claude-spt.exe
#   └── x86_64-unknown-linux-gnu/
#       └── claude-spt
#
# spt-core CLASSIFIES on top-level entry names: a known target-triple dir ⇒ multi-platform; it places
# the shared-root entries + FLATTENS this node's <triple>/* into the install dir (so a bare-name
# command token still resolves at <install_dir>/<program>, REQ-INSTALL-11). This RETIRES the F-014
# per-OS stopgap (the old single-OS `adapter.spt`-by-default that broke on a foreign host).
#
# CONSTRAINTS (doyle, extract_release_archive): only x86_64-pc-windows-msvc + x86_64-unknown-linux-gnu
# are recognized triples — an UNRECOGNIZED dir name is silently treated as a shared-root entry and
# lands flat (no error), so for platforms beyond these two ship a separate single-triple asset via
# `--asset` instead of adding a dir here. A fat archive REQUIRES the manifest's
# min_spt_core_version >= 0.13.2 (older spt-core cannot read it). DRY-RUN by default; never uploads.
#
# Usage:
#   package-adapter.sh            # dry-run: validate + print the archive plan, write nothing
#   package-adapter.sh --apply    # write dist/adapter.spt (validated)
set -u
HERE=$(CDPATH= cd "$(dirname "$0")" && pwd)
ROOT=$(CDPATH= cd "$HERE/../.." && pwd)
ADAPTER="$ROOT/adapter"
MANIFEST="$ADAPTER/claude-spt.toml"   # renamed to manifest.toml INSIDE the archive (root-only rule)
STRINGS="$ADAPTER/strings"
TOOLS="$ROOT/tools"
OUT="${ADAPTER_SPT_OUT:-$ROOT/dist/adapter.spt}"   # overridable so the unit test writes to a tmp file
APPLY=0
[ "${1:-}" = "--apply" ] && APPLY=1

# The recognized triples and where each platform's release binaries live. A native Windows build lands
# in target/release; the Linux build is cross-compiled (cargo-zigbuild) into
# target/x86_64-unknown-linux-gnu/release. Override SPTC_WIN_RELSUB / SPTC_LINUX_RELSUB for a
# non-default layout. The two tool binaries each live in their own crate under tools/ (the former
# claude-spt-digest + claude-spt-psyche crates are now the digest/psyche subcommands of claude-spt;
# cc-spt-idle-translate stays separate until doyle ask #3 lets it fold in too — ADR-0006/U2).
WIN_TRIPLE=x86_64-pc-windows-msvc
LINUX_TRIPLE=x86_64-unknown-linux-gnu
WIN_RELSUB="${SPTC_WIN_RELSUB:-release}"
LINUX_RELSUB="${SPTC_LINUX_RELSUB:-$LINUX_TRIPLE/release}"
BINS="claude-spt cc-spt-idle-translate"

# Validate the manifest first — refuse to ship an invalid adapter.
echo "== validate manifest =="
if ! sh "$ROOT/ci/manifest/check-manifest.sh"; then
  echo "REFUSING to package: manifest failed schema validation (fix above, then retry)." >&2
  exit 1
fi

# A fat archive is only readable on spt-core >= 0.13.2 — the manifest MUST declare that floor.
floor=$(grep -E '^min_spt_core_version' "$MANIFEST" | head -1 | sed -E 's/.*"([^"]+)".*/\1/')
case "$floor" in
  0.13.2|0.13.[3-9]*|0.1[4-9].*|0.[2-9][0-9].*|[1-9].*) : ;;
  *) echo "REFUSING to package: min_spt_core_version is '$floor' but a multi-platform fat .spt needs >= 0.13.2 (doyle)." >&2; exit 1 ;;
esac

# Require BOTH platforms' release binaries (NOT auto-copied by register — they ride the archive).
echo
echo "== tool binaries =="
missing=0
win_path()   { echo "$TOOLS/$1/target/$WIN_RELSUB/$1.exe"; }
linux_path() { echo "$TOOLS/$1/target/$LINUX_RELSUB/$1"; }
for b in $BINS; do
  for p in "$(win_path "$b")" "$(linux_path "$b")"; do
    if [ -f "$p" ]; then echo "  ok    $p"; else echo "  MISS  $p"; missing=1; fi
  done
done
if [ "$missing" -ne 0 ]; then
  echo "REFUSING to package: build BOTH platforms first — Windows: sh ci/{digest,idle-translate}/build.sh;" >&2
  echo "Linux (cross): cargo-zigbuild --release --target $LINUX_TRIPLE for each tool crate (see docs/RELEASE-RUNBOOK.md)." >&2
  exit 1
fi

echo
echo "== plan ($([ "$APPLY" -eq 1 ] && echo APPLY || echo DRY-RUN)) =="
echo "manifest : $MANIFEST  ->  (root) manifest.toml          [min_spt_core $floor]"
echo "strings  : $STRINGS/  ->  (root) strings/"
echo "win bins : -> $WIN_TRIPLE/{$(echo $BINS | tr ' ' ',')}.exe"
echo "linux bins: -> $LINUX_TRIPLE/{$(echo $BINS | tr ' ' ',')}"
echo "asset    : $OUT  (the single fat adapter.spt — auto-resolves the host triple on install)"

if [ "$APPLY" -ne 1 ]; then
  echo
  echo "DRY-RUN: nothing written. Re-run with --apply to write $OUT, then attach it as 'adapter.spt'"
  echo "to a GitHub release on the monorepo. End users: 'spt adapter add --release SaberMage/claude-spt'"
  echo "(default asset adapter.spt). Needs spt v0.13.2+. See docs/RELEASE-RUNBOOK.md."
  exit 0
fi

# Stage the archive ROOT in a temp dir, then tar from it so paths are root-relative (no leading dirs).
STAGE=$(mktemp -d "${TMPDIR:-/tmp}/sptc-adapter.XXXXXX") || { echo "FATAL: mktemp failed"; exit 2; }
trap 'rm -rf "$STAGE"' EXIT
cp "$MANIFEST" "$STAGE/manifest.toml"
cp -r "$STRINGS" "$STAGE/strings"
mkdir -p "$STAGE/$WIN_TRIPLE" "$STAGE/$LINUX_TRIPLE"
for b in $BINS; do
  cp "$(win_path "$b")"   "$STAGE/$WIN_TRIPLE/$b.exe"
  cp "$(linux_path "$b")" "$STAGE/$LINUX_TRIPLE/$b"
done

mkdir -p "$(dirname "$OUT")" || { echo "FATAL: cannot create $(dirname "$OUT")"; exit 2; }
( cd "$STAGE" && tar -czf "$OUT" manifest.toml strings "$WIN_TRIPLE" "$LINUX_TRIPLE" ) \
  || { echo "FATAL: tar failed"; exit 2; }

# Self-validate the produced archive: shared manifest at ROOT + BOTH triple dirs present.
echo
echo "== validate archive =="
listing=$(tar -tzf "$OUT")
fatal=0
echo "$listing" | grep -qx "manifest.toml" || { echo "FATAL: manifest.toml not at archive root" >&2; fatal=1; }
echo "$listing" | grep -q "^$WIN_TRIPLE/claude-spt.exe$" || { echo "FATAL: missing $WIN_TRIPLE/ binaries" >&2; fatal=1; }
echo "$listing" | grep -q "^$LINUX_TRIPLE/claude-spt$"   || { echo "FATAL: missing $LINUX_TRIPLE/ binaries" >&2; fatal=1; }
# Guard the footgun: no UNRECOGNIZED top-level dir (would silently flatten as a shared-root entry).
badtop=$(echo "$listing" | awk -F/ 'NF>1 {print $1}' | sort -u | grep -vE "^(strings|$WIN_TRIPLE|$LINUX_TRIPLE)$" || true)
[ -n "$badtop" ] && { echo "FATAL: unrecognized top-level dir(s) [$badtop] — spt-core would mis-place them flat" >&2; fatal=1; }
[ "$fatal" -eq 0 ] || exit 1
echo "ok   manifest.toml + strings/ at root; $WIN_TRIPLE/ + $LINUX_TRIPLE/ binaries present; no stray top-level dirs"

echo
echo "WROTE $OUT (single fat adapter.spt). Next (operator): attach as 'adapter.spt' to a GitHub release"
echo "on SaberMage/claude-spt; end users 'spt adapter add --release SaberMage/claude-spt' (spt"
echo "v0.13.2+). The fat archive auto-resolves the host's binaries — no per-OS --asset needed."
exit 0

-# non-default layout. The two tool binaries each live in their own crate under tools/ (the former

-# claude-spt-digest + claude-spt-psyche crates are now the digest/psyche subcommands of claude-spt;

-# cc-spt-idle-translate stays separate until doyle ask #3 lets it fold in too — ADR-0006/U2).

+# non-default layout. There is now ONE tool binary: the consolidated claude-spt crate carries all four

+# subcommands — digest / psyche / post-update / translate (the last folded in at the v0.8.0 cut once

+# spt-core v0.16.0 gave [message-idle-translation-binary] a `command` field — ADR-0006/D3).


echo "REFUSING to package: build BOTH platforms first — Windows: sh ci/{digest,idle-translate}/build.sh;" >&2

echo "REFUSING to package: build BOTH platforms first — Windows: sh ci/digest/build.sh;" >&2


#!/bin/sh
# Pack the SINGLE MULTI-PLATFORM `adapter.spt` release asset — the end-user distribution acquired by
# `spt adapter add --release SaberMage/claude-spt` and carried by the `[update] gh_release`
# avenue. ONE fat archive covers every supported platform (ADR-0024 W1, spt-core >= 0.13.2).
# The release/install/update repo is `SaberMage/claude-spt` (renamed from spt-claude-code, U3/ADR-0005).
# [impl->REQ-DIST-ADAPTER-RELEASE] [impl->REQ-DIST-ADAPTER-PEROS] [impl->REQ-DIST-NAME-UNIFY]
#
#   adapter.spt (tar.gz)
#   ├── manifest.toml                 ← SHARED, at archive root (renamed from claude-spt.toml)
#   ├── strings/                      ← SHARED, at archive root
#   ├── x86_64-pc-windows-msvc/       ← this triple's binary, mirroring the flat-root tree
#   │   ├── claude-spt.exe
#   └── x86_64-unknown-linux-gnu/
#       └── claude-spt
#
# spt-core CLASSIFIES on top-level entry names: a known target-triple dir ⇒ multi-platform; it places
# the shared-root entries + FLATTENS this node's <triple>/* into the install dir (so a bare-name
# command token still resolves at <install_dir>/<program>, REQ-INSTALL-11). This RETIRES the F-014
# per-OS stopgap (the old single-OS `adapter.spt`-by-default that broke on a foreign host).
#
# CONSTRAINTS (doyle, extract_release_archive): only x86_64-pc-windows-msvc + x86_64-unknown-linux-gnu
# are recognized triples — an UNRECOGNIZED dir name is silently treated as a shared-root entry and
# lands flat (no error), so for platforms beyond these two ship a separate single-triple asset via
# `--asset` instead of adding a dir here. A fat archive REQUIRES the manifest's
# min_spt_core_version >= 0.13.2 (older spt-core cannot read it). DRY-RUN by default; never uploads.
#
# Usage:
#   package-adapter.sh            # dry-run: validate + print the archive plan, write nothing
#   package-adapter.sh --apply    # write dist/adapter.spt (validated)
set -u
HERE=$(CDPATH= cd "$(dirname "$0")" && pwd)
ROOT=$(CDPATH= cd "$HERE/../.." && pwd)
ADAPTER="$ROOT/adapter"
MANIFEST="$ADAPTER/claude-spt.toml"   # renamed to manifest.toml INSIDE the archive (root-only rule)
STRINGS="$ADAPTER/strings"
TOOLS="$ROOT/tools"
OUT="${ADAPTER_SPT_OUT:-$ROOT/dist/adapter.spt}"   # overridable so the unit test writes to a tmp file
APPLY=0
[ "${1:-}" = "--apply" ] && APPLY=1

# The recognized triples and where each platform's release binaries live. A native Windows build lands
# in target/release; the Linux build is cross-compiled (cargo-zigbuild) into
# target/x86_64-unknown-linux-gnu/release. Override SPTC_WIN_RELSUB / SPTC_LINUX_RELSUB for a
# non-default layout. There is now ONE tool binary: the consolidated claude-spt crate carries all four
# subcommands — digest / psyche / post-update / translate (the last folded in at the v0.8.0 cut once
# spt-core v0.16.0 gave [message-idle-translation-binary] a `command` field — ADR-0006/D3).
WIN_TRIPLE=x86_64-pc-windows-msvc
LINUX_TRIPLE=x86_64-unknown-linux-gnu
WIN_RELSUB="${SPTC_WIN_RELSUB:-release}"
LINUX_RELSUB="${SPTC_LINUX_RELSUB:-$LINUX_TRIPLE/release}"
BINS="claude-spt"

# Validate the manifest first — refuse to ship an invalid adapter.
echo "== validate manifest =="
if ! sh "$ROOT/ci/manifest/check-manifest.sh"; then
  echo "REFUSING to package: manifest failed schema validation (fix above, then retry)." >&2
  exit 1
fi

# A fat archive is only readable on spt-core >= 0.13.2 — the manifest MUST declare that floor.
floor=$(grep -E '^min_spt_core_version' "$MANIFEST" | head -1 | sed -E 's/.*"([^"]+)".*/\1/')
case "$floor" in
  0.13.2|0.13.[3-9]*|0.1[4-9].*|0.[2-9][0-9].*|[1-9].*) : ;;
  *) echo "REFUSING to package: min_spt_core_version is '$floor' but a multi-platform fat .spt needs >= 0.13.2 (doyle)." >&2; exit 1 ;;
esac

# Require BOTH platforms' release binaries (NOT auto-copied by register — they ride the archive).
echo
echo "== tool binaries =="
missing=0
win_path()   { echo "$TOOLS/$1/target/$WIN_RELSUB/$1.exe"; }
linux_path() { echo "$TOOLS/$1/target/$LINUX_RELSUB/$1"; }
for b in $BINS; do
  for p in "$(win_path "$b")" "$(linux_path "$b")"; do
    if [ -f "$p" ]; then echo "  ok    $p"; else echo "  MISS  $p"; missing=1; fi
  done
done
if [ "$missing" -ne 0 ]; then
  echo "REFUSING to package: build BOTH platforms first — Windows: sh ci/{digest,idle-translate}/build.sh;" >&2
  echo "Linux (cross): cargo-zigbuild --release --target $LINUX_TRIPLE for each tool crate (see docs/RELEASE-RUNBOOK.md)." >&2
  exit 1
fi

echo
echo "== plan ($([ "$APPLY" -eq 1 ] && echo APPLY || echo DRY-RUN)) =="
echo "manifest : $MANIFEST  ->  (root) manifest.toml          [min_spt_core $floor]"
echo "strings  : $STRINGS/  ->  (root) strings/"
echo "win bins : -> $WIN_TRIPLE/{$(echo $BINS | tr ' ' ',')}.exe"
echo "linux bins: -> $LINUX_TRIPLE/{$(echo $BINS | tr ' ' ',')}"
echo "asset    : $OUT  (the single fat adapter.spt — auto-resolves the host triple on install)"

if [ "$APPLY" -ne 1 ]; then
  echo
  echo "DRY-RUN: nothing written. Re-run with --apply to write $OUT, then attach it as 'adapter.spt'"
  echo "to a GitHub release on the monorepo. End users: 'spt adapter add --release SaberMage/claude-spt'"
  echo "(default asset adapter.spt). Needs spt v0.13.2+. See docs/RELEASE-RUNBOOK.md."
  exit 0
fi

# Stage the archive ROOT in a temp dir, then tar from it so paths are root-relative (no leading dirs).
STAGE=$(mktemp -d "${TMPDIR:-/tmp}/sptc-adapter.XXXXXX") || { echo "FATAL: mktemp failed"; exit 2; }
trap 'rm -rf "$STAGE"' EXIT
cp "$MANIFEST" "$STAGE/manifest.toml"
cp -r "$STRINGS" "$STAGE/strings"
mkdir -p "$STAGE/$WIN_TRIPLE" "$STAGE/$LINUX_TRIPLE"
for b in $BINS; do
  cp "$(win_path "$b")"   "$STAGE/$WIN_TRIPLE/$b.exe"
  cp "$(linux_path "$b")" "$STAGE/$LINUX_TRIPLE/$b"
done

mkdir -p "$(dirname "$OUT")" || { echo "FATAL: cannot create $(dirname "$OUT")"; exit 2; }
( cd "$STAGE" && tar -czf "$OUT" manifest.toml strings "$WIN_TRIPLE" "$LINUX_TRIPLE" ) \
  || { echo "FATAL: tar failed"; exit 2; }

# Self-validate the produced archive: shared manifest at ROOT + BOTH triple dirs present.
echo
echo "== validate archive =="
listing=$(tar -tzf "$OUT")
fatal=0
echo "$listing" | grep -qx "manifest.toml" || { echo "FATAL: manifest.toml not at archive root" >&2; fatal=1; }
echo "$listing" | grep -q "^$WIN_TRIPLE/claude-spt.exe$" || { echo "FATAL: missing $WIN_TRIPLE/ binaries" >&2; fatal=1; }
echo "$listing" | grep -q "^$LINUX_TRIPLE/claude-spt$"   || { echo "FATAL: missing $LINUX_TRIPLE/ binaries" >&2; fatal=1; }
# Guard the footgun: no UNRECOGNIZED top-level dir (would silently flatten as a shared-root entry).
badtop=$(echo "$listing" | awk -F/ 'NF>1 {print $1}' | sort -u | grep -vE "^(strings|$WIN_TRIPLE|$LINUX_TRIPLE)$" || true)
[ -n "$badtop" ] && { echo "FATAL: unrecognized top-level dir(s) [$badtop] — spt-core would mis-place them flat" >&2; fatal=1; }
[ "$fatal" -eq 0 ] || exit 1
echo "ok   manifest.toml + strings/ at root; $WIN_TRIPLE/ + $LINUX_TRIPLE/ binaries present; no stray top-level dirs"

echo
echo "WROTE $OUT (single fat adapter.spt). Next (operator): attach as 'adapter.spt' to a GitHub release"
echo "on SaberMage/claude-spt; end users 'spt adapter add --release SaberMage/claude-spt' (spt"
echo "v0.13.2+). The fat archive auto-resolves the host's binaries — no per-OS --asset needed."
exit 0

-  echo "REFUSING to package: build BOTH platforms first — Windows: sh ci/{digest,idle-translate}/build.sh;" >&2

+  echo "REFUSING to package: build BOTH platforms first — Windows: sh ci/digest/build.sh;" >&2


BINS="claude-spt cc-spt-idle-translate"  # consolidated claude-spt (digest/psyche/post-update) + the separate idle-translate (ADR-0006/U2)

BINS="claude-spt"  # the ONE consolidated tool binary: digest/psyche/post-update/translate subcommands (ADR-0006; translate folded D3)


The file C:\Users\decid\Documents\projects\spt-claude-code\tests\adapter-archive.sh has been updated successfully. (file state is current in your context — no need to Read it back)

#!/bin/sh
# Unit test for the adapter.spt packer (ci/publish/package-adapter.sh).
# Asserts the MULTI-PLATFORM fat-archive invariants `spt adapter add --release` depends on (ADR-0024
# W1, spt-core >= 0.13.2): one .spt holds the SHARED manifest.toml (named exactly) + strings/ at the
# archive ROOT, plus each recognized target-triple's binaries under a <triple>/ dir mirroring the
# flat-root tree. spt-core classifies on the top-level triple dir and flattens this node's triple into
# the install dir, so a bare-name command token still resolves. [unit->REQ-DIST-ADAPTER-RELEASE]
# [unit->REQ-DIST-ADAPTER-PEROS]
set -u
ROOT=$(CDPATH= cd "$(dirname "$0")/.." && pwd)
PACKER="$ROOT/ci/publish/package-adapter.sh"
WIN_TRIPLE=x86_64-pc-windows-msvc
LINUX_TRIPLE=x86_64-unknown-linux-gnu
BINS="claude-spt cc-spt-idle-translate"  # consolidated claude-spt (digest/psyche/post-update) + the separate idle-translate (ADR-0006/U2)
rc=0
fail() { printf 'FAIL: %s\n' "$1"; rc=1; }

# The fat archive needs BOTH platforms' release binaries. Probe both triples.
have_all=1
for b in $BINS; do
  [ -f "$ROOT/tools/$b/target/release/$b.exe" ] || have_all=0
  [ -f "$ROOT/tools/$b/target/$LINUX_TRIPLE/release/$b" ] || have_all=0
done

if [ "$have_all" -ne 1 ]; then
  # Missing a platform → assert the packer's REFUSE guard fires (no silent pass), then skip the
  # archive-build assertion (no hidden coverage gap). Both platforms are required for a fat archive.
  if sh "$PACKER" --apply >/dev/null 2>&1; then
    fail "packer should refuse (exit!=0) when a platform's binaries are absent"
  else
    echo "ok   packer refuses --apply when a platform's binaries absent (guard works)"
  fi
  echo "SKIP: archive-build assertion — need BOTH platforms (win native + linux cross-build) present"
  exit "$rc"
fi

# Both platforms present → build a real fat archive to a temp file and assert its structure.
TMP=$(mktemp -d "${TMPDIR:-/tmp}/sptc-archtest.XXXXXX") || { echo "FAIL: mktemp"; exit 1; }
trap 'rm -rf "$TMP"' EXIT
OUT="$TMP/adapter.spt"

if ADAPTER_SPT_OUT="$OUT" sh "$PACKER" --apply >/dev/null 2>&1; then
  echo "ok   packer --apply succeeded"
else
  fail "packer --apply exited non-zero"
  exit "$rc"
fi
[ -f "$OUT" ] || { fail "no archive written at $OUT"; exit "$rc"; }

LIST=$(tar -tzf "$OUT")
# SHARED manifest.toml MUST be at the root (exact, no leading path) — the add --release contract.
echo "$LIST" | grep -qx "manifest.toml" \
  && echo "ok   shared manifest.toml at archive root" \
  || fail "manifest.toml not at archive root (add --release would reject)"
# SHARED strings/ at root (not under a triple).
echo "$LIST" | grep -q "^strings/" \
  && echo "ok   shared strings/ at root" \
  || fail "strings/ missing from archive root"
# Each triple carries all the tool binaries, under its <triple>/ dir, mirroring the flat-root tree.
for b in $BINS; do
  echo "$LIST" | grep -qx "$WIN_TRIPLE/$b.exe" \
    && echo "ok   $WIN_TRIPLE/$b.exe present" \
    || fail "$WIN_TRIPLE/$b.exe missing from archive"
  echo "$LIST" | grep -qx "$LINUX_TRIPLE/$b" \
    && echo "ok   $LINUX_TRIPLE/$b present" \
    || fail "$LINUX_TRIPLE/$b missing from archive"
done
# Negative: the SHARED files must NOT sit under a triple (would not be shared), and no nested wrapper.
if echo "$LIST" | grep -qE "^$WIN_TRIPLE/(manifest\.toml|strings/)"; then
  fail "manifest/strings duplicated under a triple — they must be shared at root only"
else
  echo "ok   manifest/strings not duplicated under a triple"
fi
# Negative: no UNRECOGNIZED top-level dir (spt-core would silently flatten it as a shared-root entry).
badtop=$(echo "$LIST" | awk -F/ 'NF>1 {print $1}' | sort -u | grep -vE "^(strings|$WIN_TRIPLE|$LINUX_TRIPLE)$" || true)
if [ -n "$badtop" ]; then
  fail "unrecognized top-level dir(s) [$badtop] — spt-core would mis-place them flat (silent footgun)"
else
  echo "ok   no stray/unrecognized top-level dirs (only strings/ + the two recognized triples)"
fi

# A fat archive REQUIRES min_spt_core_version >= 0.13.2 — the packer must refuse a lower floor.
# (Smoke the guard by reading the dry-run plan's advertised floor.)
plan=$(sh "$PACKER" 2>&1)
echo "$plan" | grep -qE "min_spt_core 0\.(1[3-9]|[2-9][0-9])" \
  && echo "ok   packer advertises a fat-capable floor (>= 0.13.2)" \
  || fail "packer floor advertisement missing/too low; plan=[$plan]"

[ "$rc" -eq 0 ] && echo "PASS: adapter-archive"
exit "$rc"

-BINS="claude-spt cc-spt-idle-translate"  # consolidated claude-spt (digest/psyche/post-update) + the separate idle-translate (ADR-0006/U2)

+BINS="claude-spt"  # the ONE consolidated tool binary: digest/psyche/post-update/translate subcommands (ADR-0006; translate folded D3)


Now the manifest-shortcut idle-translate assertion (path → command):


# [unit->REQ-DIST-IDLE-TRANSLATE]
# [message-idle-translation-binary] declares the idle-delivery filter by its bare binary name; the
# value MUST match the packed binary (tools/cc-spt-idle-translate) so spt-core can resolve+spawn it.
idle=$(grep -E '^[[:space:]]*path[[:space:]]*=[[:space:]]*"cc-spt-idle-translate"' "$MANIFEST")
if [ -n "$idle" ]; then echo "ok   [message-idle-translation-binary] path = \"cc-spt-idle-translate\""; else echo "FAIL [message-idle-translation-binary] path != \"cc-spt-idle-translate\""; fail=1; fi

# [unit->REQ-DIST-IDLE-TRANSLATE]
# [message-idle-translation-binary] declares the idle-delivery filter via `command` (spt-core v0.16.0
# seam; `path` deprecated) = the `translate` subcommand of the consolidated binary, resolved from the
# install dir via {adapter_dir} (D3 fold). MUST be `command` (not the deprecated `path`) and name the
# claude-spt translate subcommand.
idle=$(grep -E '^[[:space:]]*command[[:space:]]*=[[:space:]]*"\{adapter_dir\}/claude-spt translate"' "$MANIFEST")
if [ -n "$idle" ]; then echo "ok   [message-idle-translation-binary] command = \"{adapter_dir}/claude-spt translate\""; else echo "FAIL [message-idle-translation-binary] command != \"{adapter_dir}/claude-spt translate\""; fail=1; fi
# Regression guard: the deprecated bare `path = "cc-spt-idle-translate"` must be GONE (exactly one of path/command).
if grep -Eq '^[[:space:]]*path[[:space:]]*=[[:space:]]*"cc-spt-idle-translate"' "$MANIFEST"; then echo "FAIL deprecated [message-idle-translation-binary].path still present (both-set is refused)"; fail=1; else echo "ok   no deprecated idle-translate path (command-only)"; fi