1	#!/bin/sh
2	# Deterministic CI gates for spt-claude-code — each a binary pass/fail, no model in the loop.
3	# Runnable by hand on any fleet host: this IS the manual fallback. [impl->REQ-CI-GATES]
4	# [impl->REQ-CI-MANUAL]
5	# Gates whose artifact doesn't exist yet SKIP with a logged note (no silent caps — a skip is
6	# always announced so "green" never hides un-run coverage).
7	set -u
8	ROOT=$(CDPATH= cd "$(dirname "$0")/.." && pwd)
9	cd "$ROOT" || exit 2
10	rc=0
11	gate() { printf '\n=== GATE: %s ===\n' "$1"; }
12	fail() { printf 'FAIL: %s\n' "$1"; rc=1; }
13
14	gate "shell-syntax (sh -n)"
15	for f in $(find plugin tests ci -name '*.sh' 2>/dev/null | sort); do
16	  if sh -n "$f"; then printf 'ok  %s\n' "$f"; else fail "syntax: $f"; fi
17	done
18
19	gate "unit tests (tests/*.sh)"
20	for t in tests/*.sh; do
21	  [ -e "$t" ] || continue
22	  if sh "$t" >/dev/null 2>&1; then printf 'ok  %s\n' "$t"; else fail "unit: $t"; sh "$t" || true; fi
23	done
24
25	gate "digest-extractor (cargo build + test)"
26	if [ -f "$ROOT/ci/digest/build.sh" ]; then
27	  if sh "$ROOT/ci/digest/build.sh"; then :; else fail "digest-extractor"; fi
28	else
29	  echo "SKIP: no digest extractor build yet"
30	fi
31
32	gate "psyche-runner (cargo build + test)"
33	if [ -f "$ROOT/ci/psyche/build.sh" ]; then
34	  if sh "$ROOT/ci/psyche/build.sh"; then :; else fail "psyche-runner"; fi
35	else
36	  echo "SKIP: no psyche runner build yet"
37	fi
38
39	gate "traceable-reqs check (requirement coverage)"
40	if command -v traceable-reqs >/dev/null 2>&1; then
41	  if traceable-reqs check >/dev/null 2>&1; then echo "ok  coverage green"; else fail "traceable-reqs check"; traceable-reqs check || true; fi
42	else
43	  echo "SKIP: traceable-reqs not on PATH (install per docs/TRACEABILITY.md)"
44	fi
45
46	gate "skeleton-validate (cplugs installability)"
47	if [ -x "$ROOT/ci/publish/validate-skeleton.sh" ] || [ -f "$ROOT/ci/publish/validate-skeleton.sh" ]; then
48	  if sh "$ROOT/ci/publish/validate-skeleton.sh" >/dev/null 2>&1; then echo "ok  skeleton installable"; else fail "skeleton-validate"; sh "$ROOT/ci/publish/validate-skeleton.sh" || true; fi
49	else
50	  echo "SKIP: no skeleton validator yet"
51	fi
52
53	gate "manifest-schema"
54	if [ -f "$ROOT/ci/manifest/check-manifest.sh" ]; then
55	  if sh "$ROOT/ci/manifest/check-manifest.sh"; then :; else fail "manifest-schema"; fi
56	else
57	  echo "SKIP: no adapter manifest yet — activates when the CC adapter manifest lands"
58	fi
59
60	gate "docs-drift"
61	if [ -f "$ROOT/ci/docs/check-docs.sh" ]; then
62	  if sh "$ROOT/ci/docs/check-docs.sh"; then :; else fail "docs-drift"; fi
63	else
64	  echo "SKIP: no docs check yet"
65	fi
66
67	printf '\n=== RESULT: %s ===\n' "$([ "$rc" -eq 0 ] && echo PASS || echo FAIL)"
68	exit "$rc"
69

#!/bin/sh
# Deterministic CI gates for spt-claude-code — each a binary pass/fail, no model in the loop.
# Runnable by hand on any fleet host: this IS the manual fallback. [impl->REQ-CI-GATES]
# [impl->REQ-CI-MANUAL]
# Gates whose artifact doesn't exist yet SKIP with a logged note (no silent caps — a skip is
# always announced so "green" never hides un-run coverage).
set -u
ROOT=$(CDPATH= cd "$(dirname "$0")/.." && pwd)
cd "$ROOT" || exit 2
rc=0
gate() { printf '\n=== GATE: %s ===\n' "$1"; }
fail() { printf 'FAIL: %s\n' "$1"; rc=1; }

gate "shell-syntax (sh -n)"
for f in $(find plugin tests ci -name '*.sh' 2>/dev/null | sort); do
  if sh -n "$f"; then printf 'ok  %s\n' "$f"; else fail "syntax: $f"; fi
done

gate "unit tests (tests/*.sh)"
for t in tests/*.sh; do
  [ -e "$t" ] || continue
  if sh "$t" >/dev/null 2>&1; then printf 'ok  %s\n' "$t"; else fail "unit: $t"; sh "$t" || true; fi
done

gate "digest-extractor (cargo build + test)"
if [ -f "$ROOT/ci/digest/build.sh" ]; then
  if sh "$ROOT/ci/digest/build.sh"; then :; else fail "digest-extractor"; fi
else
  echo "SKIP: no digest extractor build yet"
fi

gate "psyche-runner (cargo build + test)"
if [ -f "$ROOT/ci/psyche/build.sh" ]; then
  if sh "$ROOT/ci/psyche/build.sh"; then :; else fail "psyche-runner"; fi
else
  echo "SKIP: no psyche runner build yet"
fi

gate "traceable-reqs check (requirement coverage)"
if command -v traceable-reqs >/dev/null 2>&1; then
  if traceable-reqs check >/dev/null 2>&1; then echo "ok  coverage green"; else fail "traceable-reqs check"; traceable-reqs check || true; fi
else
  echo "SKIP: traceable-reqs not on PATH (install per docs/TRACEABILITY.md)"
fi

gate "skeleton-validate (cplugs installability)"
if [ -x "$ROOT/ci/publish/validate-skeleton.sh" ] || [ -f "$ROOT/ci/publish/validate-skeleton.sh" ]; then
  if sh "$ROOT/ci/publish/validate-skeleton.sh" >/dev/null 2>&1; then echo "ok  skeleton installable"; else fail "skeleton-validate"; sh "$ROOT/ci/publish/validate-skeleton.sh" || true; fi
else
  echo "SKIP: no skeleton validator yet"
fi

gate "manifest-schema"
if [ -f "$ROOT/ci/manifest/check-manifest.sh" ]; then
  if sh "$ROOT/ci/manifest/check-manifest.sh"; then :; else fail "manifest-schema"; fi
else
  echo "SKIP: no adapter manifest yet — activates when the CC adapter manifest lands"
fi

gate "docs-drift"
if [ -f "$ROOT/ci/docs/check-docs.sh" ]; then
  if sh "$ROOT/ci/docs/check-docs.sh"; then :; else fail "docs-drift"; fi
else
  echo "SKIP: no docs check yet"
fi

printf '\n=== RESULT: %s ===\n' "$([ "$rc" -eq 0 ] && echo PASS || echo FAIL)"
exit "$rc"


Have everything. Building now. Start with the core deliverable — the `cc-spt-idle-translate` binary.


Write

# cc-spt-idle-translate — the [message-idle-translation-binary] for the claude-spt adapter
# (spt-core harness-contract v0.13.0+). Rust for the same reasons the digest/psyche tools are:
# a compiled, dependency-light binary with no interpreter-on-PATH or locale-encoding hazards
# (stdin/stdout are always UTF-8), one .exe the fleet spawns bare on Win+Linux. spt-core
# lifecycle-manages it (spawn on endpoint up, reap on down). Builds via ci/idle-translate/build.sh,
# gated in ci/run-gates.sh. Packed into adapter.spt alongside claude-spt-digest + claude-spt-psyche.
[package]
name = "cc-spt-idle-translate"
version = "0.1.0"
edition = "2021"
publish = false

[[bin]]
name = "cc-spt-idle-translate"
path = "src/main.rs"

[dependencies]
serde_json = "1"

[profile.release]
strip = true


start with A, yes. -the objective for [message-idle-translation-binary] is for it to output this set of key events per message: ctrl+s (stash any existing message), delay 50ms, message text+`\r` (to s…


Implement message-idle-translation-binary key events


File created successfully at: C:/Users/decid/Documents/projects/spt-claude-code/tools/cc-spt-idle-translate/Cargo.toml (file state is current in your context — no need to Read it back)

# cc-spt-idle-translate — the [message-idle-translation-binary] for the claude-spt adapter
# (spt-core harness-contract v0.13.0+). Rust for the same reasons the digest/psyche tools are:
# a compiled, dependency-light binary with no interpreter-on-PATH or locale-encoding hazards
# (stdin/stdout are always UTF-8), one .exe the fleet spawns bare on Win+Linux. spt-core
# lifecycle-manages it (spawn on endpoint up, reap on down). Builds via ci/idle-translate/build.sh,
# gated in ci/run-gates.sh. Packed into adapter.spt alongside claude-spt-digest + claude-spt-psyche.
[package]
name = "cc-spt-idle-translate"
version = "0.1.0"
edition = "2021"
publish = false

[[bin]]
name = "cc-spt-idle-translate"
path = "src/main.rs"

[dependencies]
serde_json = "1"

[profile.release]
strip = true


//! cc-spt-idle-translate — the `[message-idle-translation-binary]` for the claude-spt adapter
//! (spt-core harness-contract v0.13.0+).
//!
//! A lifecycle-managed `stdin -> stdout` JSON-lines filter. spt-core spawns it when the spt-hosted
//! endpoint comes online and reaps it on shutdown; it turns each inbound `<EVENT>` envelope into a
//! sequence of keystroke-commands that spt-core applies ATOMICALLY to the broker-held PTY (live
//! operator input is buffered during emission, so it coexists with an attached `spt rc` controller).
//! Scope is IDLE delivery only — busy/mid-turn injection stays the adapter's `[inject]` hook path.
//!
//! Input protocol (stdin, one JSON object per line):
//!   {"type":"init","endpoint_id":…,"node":…}   — first message (handshake; no output)
//!   {"type":"event","envelope":"<EVENT…>"}      — one inbound message; the full EVENT envelope
//!   {"type":"input"}                            — content-free ping when the operator types (no output)
//!
//! Output protocol (stdout, one JSON object per line):
//!   {"key":"ctrl+s"}     — keystroke command (Claude Code: STASH the current draft input)
//!   {"delay_ms":50}      — inter-command pause
//!   {"text":"<payload>"} — text injection (the envelope; a trailing \r submits it)
//!   {"key":"ctrl+s"}     — keystroke command (RESTORE the stashed draft)
//!
//! The per-event choreography (operator-specified) delivers the message WITHOUT clobbering a
//! half-typed draft the operator may have in the input box:
//!   1. ctrl+s              stash any existing draft
//!   2. delay 50ms
//!   3. <envelope>\r        type the envelope, the carriage return submits it
//!   4. delay 50ms
//!   5. ctrl+s              restore the stashed draft
//!
//! Degenerate fallback (per contract): emit `{"text":payload}{"key":"enter"}` with no choreography.
//! We choreograph because Claude Code's input box supports the ctrl+s draft stash/restore, so an
//! inbound message never eats an in-progress draft.
//!
//! Robustness: this is lifecycle-critical — a panic would drop the idle-delivery pipe for the whole
//! session. Malformed lines and unknown `type`s degrade gracefully (skipped, never fatal); unknown
//! JSON fields are ignored (forward-compat with newer spt-core). [impl->REQ-DIST-IDLE-TRANSLATE]

use serde_json::{json, Value};
use std::io::{self, BufRead, Write};

/// Inter-command pause spt-core honors between emitted keystroke/text commands (operator spec).
const DELAY_MS: u64 = 50;

/// Build the keystroke-command sequence for one inbound EVENT envelope.
///
/// `envelope` is the full `<EVENT…>…</EVENT>` string. The envelope is single-line by contract (it
/// encodes newlines as the literal `<br>` token), but we defensively strip any raw CR/LF so a stray
/// newline can never submit early or split the injection — our single trailing `\r` is the ONLY
/// submit. [impl->REQ-DIST-IDLE-TRANSLATE]
fn commands_for_event(envelope: &str) -> Vec<Value> {
    let sanitized: String = envelope
        .chars()
        .map(|c| if c == '\r' || c == '\n' { ' ' } else { c })
        .collect();
    vec![
        json!({ "key": "ctrl+s" }),               // 1. stash any existing draft
        json!({ "delay_ms": DELAY_MS }),          // 2.
        json!({ "text": format!("{sanitized}\r") }), // 3. envelope + CR submits
        json!({ "delay_ms": DELAY_MS }),          // 4.
        json!({ "key": "ctrl+s" }),               // 5. restore the stashed draft
    ]
}

/// Dispatch one parsed input line to its output commands. `init`/`input` (and any unknown `type`)
/// produce nothing; only `event` emits the choreography. Returns an empty vec for no-output lines.
fn commands_for_line(v: &Value) -> Vec<Value> {
    match v.get("type").and_then(Value::as_str) {
        Some("event") => match v.get("envelope").and_then(Value::as_str) {
            Some(env) => commands_for_event(env),
            None => Vec::new(), // event without an envelope: nothing to deliver
        },
        // "init" handshake, "input" operator-typing ping, or any unknown type: no output.
        _ => Vec::new(),
    }
}

fn main() {
    let stdin = io::stdin();
    let stdout = io::stdout();
    let mut out = stdout.lock();

    for line in stdin.lock().lines() {
        let line = match line {
            Ok(l) => l,
            Err(_) => break, // stdin closed/errored: spt-core is tearing us down.
        };
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        let v: Value = match serde_json::from_str(trimmed) {
            Ok(v) => v,
            Err(e) => {
                // Skip malformed lines — never fatal. stderr lands in spt-core's log, not the PTY.
                eprintln!("cc-spt-idle-translate: skipping unparseable line: {e}");
                continue;
            }
        };
        for cmd in commands_for_line(&v) {
            // One compact JSON object per line; flush per command so spt-core applies promptly.
            if writeln!(out, "{cmd}").is_err() || out.flush().is_err() {
                return; // stdout closed: endpoint gone.
            }
        }
    }
}

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

    fn keys(cmds: &[Value]) -> Vec<String> {
        cmds.iter()
            .map(|c| {
                if let Some(k) = c.get("key").and_then(Value::as_str) {
                    format!("key:{k}")
                } else if let Some(d) = c.get("delay_ms").and_then(Value::as_u64) {
                    format!("delay:{d}")
                } else if let Some(t) = c.get("text").and_then(Value::as_str) {
                    format!("text:{t}")
                } else {
                    "?".into()
                }
            })
            .collect()
    }

    #[test]
    fn event_emits_the_five_step_choreography() {
        let cmds = commands_for_event("<EVENT type=\"msg\" from=\"doyle\">hi</EVENT>");
        assert_eq!(
            keys(&cmds),
            vec![
                "key:ctrl+s".to_string(),
                "delay:50".to_string(),
                "text:<EVENT type=\"msg\" from=\"doyle\">hi</EVENT>\r".to_string(),
                "delay:50".to_string(),
                "key:ctrl+s".to_string(),
            ]
        );
    }

    #[test]
    fn submit_is_a_trailing_carriage_return_on_the_text() {
        let cmds = commands_for_event("payload");
        let text = cmds[2].get("text").and_then(Value::as_str).unwrap();
        assert!(text.ends_with('\r'), "text must end with the submit \\r");
        assert_eq!(text, "payload\r");
        // The submit is carried IN the text, not a separate enter key (operator spec: text+\r).
        assert!(cmds.iter().all(|c| c.get("key").and_then(Value::as_str) != Some("enter")));
    }

    #[test]
    fn stash_then_restore_brackets_the_submit() {
        let cmds = commands_for_event("x");
        // ctrl+s is FIRST (stash) and LAST (restore); the text submit sits between them.
        assert_eq!(cmds.first().unwrap().get("key").and_then(Value::as_str), Some("ctrl+s"));
        assert_eq!(cmds.last().unwrap().get("key").and_then(Value::as_str), Some("ctrl+s"));
        assert!(cmds[2].get("text").is_some());
    }

    #[test]
    fn raw_newlines_in_envelope_are_neutralized() {
        // A stray CR/LF must not produce an early submit or split the injection — only the trailing
        // \r submits. Embedded newlines collapse to spaces; the single submit \r is the LAST char.
        let cmds = commands_for_event("a\nb\r\nc");
        let text = cmds[2].get("text").and_then(Value::as_str).unwrap();
        assert_eq!(text, "a b  c\r");
        assert_eq!(text.matches('\r').count(), 1, "exactly one (submit) CR");
        assert_eq!(text.matches('\n').count(), 0, "no raw LF survives");
    }

    #[test]
    fn event_dispatch_via_line() {
        let v: Value = serde_json::from_str(
            r#"{"type":"event","envelope":"<EVENT type=\"msg\" from=\"a\">b</EVENT>"}"#,
        )
        .unwrap();
        let cmds = commands_for_line(&v);
        assert_eq!(cmds.len(), 5);
        assert_eq!(cmds[0].get("key").and_then(Value::as_str), Some("ctrl+s"));
    }

    #[test]
    fn init_and_input_and_unknown_types_emit_nothing() {
        for line in [
            r#"{"type":"init","endpoint_id":"perri","node":"HFENDULEAM"}"#,
            r#"{"type":"input"}"#,
            r#"{"type":"future-thing","x":1}"#,
            r#"{"no_type":true}"#,
        ] {
            let v: Value = serde_json::from_str(line).unwrap();
            assert!(commands_for_line(&v).is_empty(), "no output for: {line}");
        }
    }

    #[test]
    fn event_without_envelope_emits_nothing() {
        let v: Value = serde_json::from_str(r#"{"type":"event"}"#).unwrap();
        assert!(commands_for_line(&v).is_empty());
    }

    #[test]
    fn unknown_fields_are_ignored_forward_compat() {
        // A newer spt-core adding fields must not break dispatch (unknown keys ignored).
        let v: Value = serde_json::from_str(
            r#"{"type":"event","envelope":"<EVENT>z</EVENT>","priority":9,"trace_id":"t"}"#,
        )
        .unwrap();
        assert_eq!(commands_for_line(&v).len(), 5);
    }

    #[test]
    fn output_commands_are_single_key_objects() {
        // Each emitted command is exactly one of the contract shapes (key | delay_ms | text).
        for c in commands_for_event("m") {
            let obj = c.as_object().unwrap();
            assert_eq!(obj.len(), 1, "each command carries exactly one field: {c}");
            let k = obj.keys().next().unwrap().as_str();
            assert!(matches!(k, "key" | "delay_ms" | "text"), "unexpected field {k}");
        }
    }
}


File created successfully at: C:/Users/decid/Documents/projects/spt-claude-code/tools/cc-spt-idle-translate/src/main.rs (file state is current in your context — no need to Read it back)

//! cc-spt-idle-translate — the `[message-idle-translation-binary]` for the claude-spt adapter
//! (spt-core harness-contract v0.13.0+).
//!
//! A lifecycle-managed `stdin -> stdout` JSON-lines filter. spt-core spawns it when the spt-hosted
//! endpoint comes online and reaps it on shutdown; it turns each inbound `<EVENT>` envelope into a
//! sequence of keystroke-commands that spt-core applies ATOMICALLY to the broker-held PTY (live
//! operator input is buffered during emission, so it coexists with an attached `spt rc` controller).
//! Scope is IDLE delivery only — busy/mid-turn injection stays the adapter's `[inject]` hook path.
//!
//! Input protocol (stdin, one JSON object per line):
//!   {"type":"init","endpoint_id":…,"node":…}   — first message (handshake; no output)
//!   {"type":"event","envelope":"<EVENT…>"}      — one inbound message; the full EVENT envelope
//!   {"type":"input"}                            — content-free ping when the operator types (no output)
//!
//! Output protocol (stdout, one JSON object per line):
//!   {"key":"ctrl+s"}     — keystroke command (Claude Code: STASH the current draft input)
//!   {"delay_ms":50}      — inter-command pause
//!   {"text":"<payload>"} — text injection (the envelope; a trailing \r submits it)
//!   {"key":"ctrl+s"}     — keystroke command (RESTORE the stashed draft)
//!
//! The per-event choreography (operator-specified) delivers the message WITHOUT clobbering a
//! half-typed draft the operator may have in the input box:
//!   1. ctrl+s              stash any existing draft
//!   2. delay 50ms
//!   3. <envelope>\r        type the envelope, the carriage return submits it
//!   4. delay 50ms
//!   5. ctrl+s              restore the stashed draft
//!
//! Degenerate fallback (per contract): emit `{"text":payload}{"key":"enter"}` with no choreography.
//! We choreograph because Claude Code's input box supports the ctrl+s draft stash/restore, so an
//! inbound message never eats an in-progress draft.
//!
//! Robustness: this is lifecycle-critical — a panic would drop the idle-delivery pipe for the whole
//! session. Malformed lines and unknown `type`s degrade gracefully (skipped, never fatal); unknown
//! JSON fields are ignored (forward-compat with newer spt-core). [impl->REQ-DIST-IDLE-TRANSLATE]

use serde_json::{json, Value};
use std::io::{self, BufRead, Write};

/// Inter-command pause spt-core honors between emitted keystroke/text commands (operator spec).
const DELAY_MS: u64 = 50;

/// Build the keystroke-command sequence for one inbound EVENT envelope.
///
/// `envelope` is the full `<EVENT…>…</EVENT>` string. The envelope is single-line by contract (it
/// encodes newlines as the literal `<br>` token), but we defensively strip any raw CR/LF so a stray
/// newline can never submit early or split the injection — our single trailing `\r` is the ONLY
/// submit. [impl->REQ-DIST-IDLE-TRANSLATE]
fn commands_for_event(envelope: &str) -> Vec<Value> {
    let sanitized: String = envelope
        .chars()
        .map(|c| if c == '\r' || c == '\n' { ' ' } else { c })
        .collect();
    vec![
        json!({ "key": "ctrl+s" }),               // 1. stash any existing draft
        json!({ "delay_ms": DELAY_MS }),          // 2.
        json!({ "text": format!("{sanitized}\r") }), // 3. envelope + CR submits
        json!({ "delay_ms": DELAY_MS }),          // 4.
        json!({ "key": "ctrl+s" }),               // 5. restore the stashed draft
    ]
}

/// Dispatch one parsed input line to its output commands. `init`/`input` (and any unknown `type`)
/// produce nothing; only `event` emits the choreography. Returns an empty vec for no-output lines.
fn commands_for_line(v: &Value) -> Vec<Value> {
    match v.get("type").and_then(Value::as_str) {
        Some("event") => match v.get("envelope").and_then(Value::as_str) {
            Some(env) => commands_for_event(env),
            None => Vec::new(), // event without an envelope: nothing to deliver
        },
        // "init" handshake, "input" operator-typing ping, or any unknown type: no output.
        _ => Vec::new(),
    }
}

fn main() {
    let stdin = io::stdin();
    let stdout = io::stdout();
    let mut out = stdout.lock();

    for line in stdin.lock().lines() {
        let line = match line {
            Ok(l) => l,
            Err(_) => break, // stdin closed/errored: spt-core is tearing us down.
        };
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        let v: Value = match serde_json::from_str(trimmed) {
            Ok(v) => v,
            Err(e) => {
                // Skip malformed lines — never fatal. stderr lands in spt-core's log, not the PTY.
                eprintln!("cc-spt-idle-translate: skipping unparseable line: {e}");
                continue;
            }
        };
        for cmd in commands_for_line(&v) {
            // One compact JSON object per line; flush per command so spt-core applies promptly.
            if writeln!(out, "{cmd}").is_err() || out.flush().is_err() {
                return; // stdout closed: endpoint gone.
            }
        }
    }
}

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

    fn keys(cmds: &[Value]) -> Vec<String> {
        cmds.iter()
            .map(|c| {
                if let Some(k) = c.get("key").and_then(Value::as_str) {
                    format!("key:{k}")
                } else if let Some(d) = c.get("delay_ms").and_then(Value::as_u64) {
                    format!("delay:{d}")
                } else if let Some(t) = c.get("text").and_then(Value::as_str) {
                    format!("text:{t}")
                } else {
                    "?".into()
                }
            })
            .collect()
    }

    #[test]
    fn event_emits_the_five_step_choreography() {
        let cmds = commands_for_event("<EVENT type=\"msg\" from=\"doyle\">hi</EVENT>");
        assert_eq!(
            keys(&cmds),
            vec![
                "key:ctrl+s".to_string(),
                "delay:50".to_string(),
                "text:<EVENT type=\"msg\" from=\"doyle\">hi</EVENT>\r".to_string(),
                "delay:50".to_string(),
                "key:ctrl+s".to_string(),
            ]
        );
    }

    #[test]
    fn submit_is_a_trailing_carriage_return_on_the_text() {
        let cmds = commands_for_event("payload");
        let text = cmds[2].get("text").and_then(Value::as_str).unwrap();
        assert!(text.ends_with('\r'), "text must end with the submit \\r");
        assert_eq!(text, "payload\r");
        // The submit is carried IN the text, not a separate enter key (operator spec: text+\r).
        assert!(cmds.iter().all(|c| c.get("key").and_then(Value::as_str) != Some("enter")));
    }

    #[test]
    fn stash_then_restore_brackets_the_submit() {
        let cmds = commands_for_event("x");
        // ctrl+s is FIRST (stash) and LAST (restore); the text submit sits between them.
        assert_eq!(cmds.first().unwrap().get("key").and_then(Value::as_str), Some("ctrl+s"));
        assert_eq!(cmds.last().unwrap().get("key").and_then(Value::as_str), Some("ctrl+s"));
        assert!(cmds[2].get("text").is_some());
    }

    #[test]
    fn raw_newlines_in_envelope_are_neutralized() {
        // A stray CR/LF must not produce an early submit or split the injection — only the trailing
        // \r submits. Embedded newlines collapse to spaces; the single submit \r is the LAST char.
        let cmds = commands_for_event("a\nb\r\nc");
        let text = cmds[2].get("text").and_then(Value::as_str).unwrap();
        assert_eq!(text, "a b  c\r");
        assert_eq!(text.matches('\r').count(), 1, "exactly one (submit) CR");
        assert_eq!(text.matches('\n').count(), 0, "no raw LF survives");
    }

    #[test]
    fn event_dispatch_via_line() {
        let v: Value = serde_json::from_str(
            r#"{"type":"event","envelope":"<EVENT type=\"msg\" from=\"a\">b</EVENT>"}"#,
        )
        .unwrap();
        let cmds = commands_for_line(&v);
        assert_eq!(cmds.len(), 5);
        assert_eq!(cmds[0].get("key").and_then(Value::as_str), Some("ctrl+s"));
    }

    #[test]
    fn init_and_input_and_unknown_types_emit_nothing() {
        for line in [
            r#"{"type":"init","endpoint_id":"perri","node":"HFENDULEAM"}"#,
            r#"{"type":"input"}"#,
            r#"{"type":"future-thing","x":1}"#,
            r#"{"no_type":true}"#,
        ] {
            let v: Value = serde_json::from_str(line).unwrap();
            assert!(commands_for_line(&v).is_empty(), "no output for: {line}");
        }
    }

    #[test]
    fn event_without_envelope_emits_nothing() {
        let v: Value = serde_json::from_str(r#"{"type":"event"}"#).unwrap();
        assert!(commands_for_line(&v).is_empty());
    }

    #[test]
    fn unknown_fields_are_ignored_forward_compat() {
        // A newer spt-core adding fields must not break dispatch (unknown keys ignored).
        let v: Value = serde_json::from_str(
            r#"{"type":"event","envelope":"<EVENT>z</EVENT>","priority":9,"trace_id":"t"}"#,
        )
        .unwrap();
        assert_eq!(commands_for_line(&v).len(), 5);
    }

    #[test]
    fn output_commands_are_single_key_objects() {
        // Each emitted command is exactly one of the contract shapes (key | delay_ms | text).
        for c in commands_for_event("m") {
            let obj = c.as_object().unwrap();
            assert_eq!(obj.len(), 1, "each command carries exactly one field: {c}");
            let k = obj.keys().next().unwrap().as_str();
            assert!(matches!(k, "key" | "delay_ms" | "text"), "unexpected field {k}");
        }
    }
}


Now build + test the crate to confirm it compiles green.


Bash

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

cargo test the new binary


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


9/9 green. Now wire the manifest. Add `[session.resume]` after `[session.self]`.


Edit

[session.self]
command = "claude --dangerously-skip-permissions"
keys = ["id"]

# ── [env.<VAR>] ────────────────────────────────────────────────────────────────────────────

[session.self]
command = "claude --dangerously-skip-permissions"
keys = ["id"]

# ── [session.resume] — native-resume sibling of [session.self] (harness-contract v0.13.0+) ─────
# spt-core selects THIS over [session.self] when a bringup carries a prior session (`spt endpoint run
# --resume <session>` or the picker's "Resume from history") AND this role is declared. OMITTING it
# silently falls back to [session.self] → a fresh session with a BLANK transcript (the documented
# footgun). The PTY lands in the resumed session's recorded project cwd; CC resolves the transcript by
# {session_id} + cwd.
#
# Command = Claude Code's NATIVE-RESUME verb: `claude -r {session_id}` (-r/--resume reloads the real
# transcript by id). Unlike [session.self] — which threads the endpoint {id} via $SPT_ENDPOINT_ID
# because bare `claude` has no id flag — resume threads {id} as the REMOTE-CONTROL name
# (`--remote-control {id}`, a real CC flag): native-resume drives the resumed PTY over Remote Control,
# the same channel a live `spt rc` controller / the [message-idle-translation-binary] use, so the
# endpoint id IS the RC session name. `--dangerously-skip-permissions` for the same non-interactive
# broker-PTY reason as [session.self] (REQ-HAZARD-PSYCHE-PERMS-DEADLOCK). keys = the two fills
# spt-core substitutes: {session_id} (the resumed id) + {id} (the endpoint). spt tokenizes on
# whitespace then fills (tokenize-then-fill, v0.8.2+), so each flag is a clean argv element.
# [impl->REQ-DIST-SESSION-RESUME]
[session.resume]
command = "claude -r {session_id} --remote-control {id} --dangerously-skip-permissions"
keys = ["session_id", "id"]

# ── [env.<VAR>] ────────────────────────────────────────────────────────────────────────────


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)

[session.self]
command = "claude --dangerously-skip-permissions"
keys = ["id"]

# ── [env.<VAR>] ────────────────────────────────────────────────────────────────────────────

[session.self]
command = "claude --dangerously-skip-permissions"
keys = ["id"]

# ── [session.resume] — native-resume sibling of [session.self] (harness-contract v0.13.0+) ─────
# spt-core selects THIS over [session.self] when a bringup carries a prior session (`spt endpoint run
# --resume <session>` or the picker's "Resume from history") AND this role is declared. OMITTING it
# silently falls back to [session.self] → a fresh session with a BLANK transcript (the documented
# footgun). The PTY lands in the resumed session's recorded project cwd; CC resolves the transcript by
# {session_id} + cwd.
#
# Command = Claude Code's NATIVE-RESUME verb: `claude -r {session_id}` (-r/--resume reloads the real
# transcript by id). Unlike [session.self] — which threads the endpoint {id} via $SPT_ENDPOINT_ID
# because bare `claude` has no id flag — resume threads {id} as the REMOTE-CONTROL name
# (`--remote-control {id}`, a real CC flag): native-resume drives the resumed PTY over Remote Control,
# the same channel a live `spt rc` controller / the [message-idle-translation-binary] use, so the
# endpoint id IS the RC session name. `--dangerously-skip-permissions` for the same non-interactive
# broker-PTY reason as [session.self] (REQ-HAZARD-PSYCHE-PERMS-DEADLOCK). keys = the two fills
# spt-core substitutes: {session_id} (the resumed id) + {id} (the endpoint). spt tokenizes on
# whitespace then fills (tokenize-then-fill, v0.8.2+), so each flag is a clean argv element.
# [impl->REQ-DIST-SESSION-RESUME]
[session.resume]
command = "claude -r {session_id} --remote-control {id} --dangerously-skip-permissions"
keys = ["session_id", "id"]

# ── [env.<VAR>] ────────────────────────────────────────────────────────────────────────────

+# ── [session.resume] — native-resume sibling of [session.self] (harness-contract v0.13.0+) ─────

+# spt-core selects THIS over [session.self] when a bringup carries a prior session (`spt endpoint run

+# --resume <session>` or the picker's "Resume from history") AND this role is declared. OMITTING it

+# silently falls back to [session.self] → a fresh session with a BLANK transcript (the documented

+# footgun). The PTY lands in the resumed session's recorded project cwd; CC resolves the transcript by

+# Command = Claude Code's NATIVE-RESUME verb: `claude -r {session_id}` (-r/--resume reloads the real

+# transcript by id). Unlike [session.self] — which threads the endpoint {id} via $SPT_ENDPOINT_ID

+# because bare `claude` has no id flag — resume threads {id} as the REMOTE-CONTROL name

+# (`--remote-control {id}`, a real CC flag): native-resume drives the resumed PTY over Remote Control,

+# the same channel a live `spt rc` controller / the [message-idle-translation-binary] use, so the

+# endpoint id IS the RC session name. `--dangerously-skip-permissions` for the same non-interactive

+# broker-PTY reason as [session.self] (REQ-HAZARD-PSYCHE-PERMS-DEADLOCK). keys = the two fills

+# spt-core substitutes: {session_id} (the resumed id) + {id} (the endpoint). spt tokenizes on

+# whitespace then fills (tokenize-then-fill, v0.8.2+), so each flag is a clean argv element.

+command = "claude -r {session_id} --remote-control {id} --dangerously-skip-permissions"

# Broker-injected env for spt-hosted (bind-path) sessions. The endpoint {id} is injected so CC's

# SessionStart hook can self-register the right perch via `api bind $SPT_ENDPOINT_ID`. spt-hosted


Now `[message-idle-translation-binary]` after the `[inject]` block.


# ── [inject] ───────────────────────────────────────────────────────────────────────────────
[inject]
# v1 delivery is the hook channel only (UserPromptSubmit/SessionStart additionalContext).
# PTY injection is spt-core M3 roadmap (F-001 resolution); not claimed here.
activity = ["hook"]
idle = ["hook"]

# ── [inject] ───────────────────────────────────────────────────────────────────────────────
[inject]
# v1 delivery is the hook channel only (UserPromptSubmit/SessionStart additionalContext).
# PTY injection is spt-core M3 roadmap (F-001 resolution); not claimed here.
activity = ["hook"]
idle = ["hook"]

# ── [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 claude-spt-digest /
# claude-spt-psyche; 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) · 50ms · ctrl+s (restore draft) — so an inbound message never clobbers a half-typed draft.
# 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). [impl->REQ-DIST-IDLE-TRANSLATE]
[message-idle-translation-binary]
path = "cc-spt-idle-translate"


# ── [inject] ───────────────────────────────────────────────────────────────────────────────
[inject]
# v1 delivery is the hook channel only (UserPromptSubmit/SessionStart additionalContext).
# PTY injection is spt-core M3 roadmap (F-001 resolution); not claimed here.
activity = ["hook"]
idle = ["hook"]

# ── [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 claude-spt-digest /
# claude-spt-psyche; 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) · 50ms · ctrl+s (restore draft) — so an inbound message never clobbers a half-typed draft.
# 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). [impl->REQ-DIST-IDLE-TRANSLATE]
[message-idle-translation-binary]
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 claude-spt-digest /

+# claude-spt-psyche; 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) · 50ms · ctrl+s (restore draft) — so an inbound message never clobbers a half-typed draft.

+# 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). [impl->REQ-DIST-IDLE-TRANSLATE]

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

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

# extractor maps that native log → the published {role,text,tool,ts} digest-record contract,


Update the skip-perms test — `[session.resume]` adds a third non-interactive CC spawn command.


# [unit->REQ-HAZARD-PSYCHE-PERMS-DEADLOCK]
# Non-interactive bringup: BOTH [session.self] CC spawn commands (base `claude`, the `ccs` profile)
# MUST carry --dangerously-skip-permissions. The broker spawns CC into a PTY with no operator, so an
# interactive permission prompt would deadlock the launch (docs/KNOWN-HAZARDS.md §2.2). Match the
# active `command =` lines whose value is a bare claude/ccs CC spawn — NOT `claude-spt-psyche` (the
# runner, whose own turns are asserted in tools/claude-spt-psyche unit tests).
spawn=$(grep -E '^[[:space:]]*command[[:space:]]*=[[:space:]]*"(claude|ccs)([[:space:]]|")' "$MANIFEST")
nspawn=$(printf '%s' "$spawn" | grep -c .)
if [ "$nspawn" -eq 2 ]; then echo "ok   2 [session.self] CC spawn commands (base claude + ccs)"; else echo "FAIL expected 2 CC spawn commands, found $nspawn"; fail=1; fi

# [unit->REQ-HAZARD-PSYCHE-PERMS-DEADLOCK]
# Non-interactive bringup: ALL THREE CC spawn commands — the two [session.self] (base `claude`, the
# `ccs` profile) AND [session.resume] (`claude -r … --remote-control …`) — MUST carry
# --dangerously-skip-permissions. The broker spawns CC into a PTY with no operator, so an interactive
# permission prompt would deadlock the launch (docs/KNOWN-HAZARDS.md §2.2). Match the active
# `command =` lines whose value is a bare claude/ccs CC spawn — NOT `claude-spt-psyche` (the runner,
# whose own turns are asserted in tools/claude-spt-psyche unit tests).
spawn=$(grep -E '^[[:space:]]*command[[:space:]]*=[[:space:]]*"(claude|ccs)([[:space:]]|")' "$MANIFEST")
nspawn=$(printf '%s' "$spawn" | grep -c .)
if [ "$nspawn" -eq 3 ]; then echo "ok   3 CC spawn commands ([session.self] base claude + ccs, [session.resume])"; else echo "FAIL expected 3 CC spawn commands, found $nspawn"; fail=1; fi


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

# [unit->REQ-HAZARD-PSYCHE-PERMS-DEADLOCK]
# Non-interactive bringup: ALL THREE CC spawn commands — the two [session.self] (base `claude`, the
# `ccs` profile) AND [session.resume] (`claude -r … --remote-control …`) — MUST carry
# --dangerously-skip-permissions. The broker spawns CC into a PTY with no operator, so an interactive
# permission prompt would deadlock the launch (docs/KNOWN-HAZARDS.md §2.2). Match the active
# `command =` lines whose value is a bare claude/ccs CC spawn — NOT `claude-spt-psyche` (the runner,
# whose own turns are asserted in tools/claude-spt-psyche unit tests).
spawn=$(grep -E '^[[:space:]]*command[[:space:]]*=[[:space:]]*"(claude|ccs)([[:space:]]|")' "$MANIFEST")
nspawn=$(printf '%s' "$spawn" | grep -c .)
if [ "$nspawn" -eq 3 ]; then echo "ok   3 CC spawn commands ([session.self] base claude + ccs, [session.resume])"; else echo "FAIL expected 3 CC spawn commands, found $nspawn"; fail=1; fi

#!/bin/sh
# Brand-value regression guard for the `spt endpoint run` shortcut: the manifest MUST declare
# adapter.shortcut_basename = "cc" (→ cc-<id>, the M12 cc launcher). A drift to "sptc"/"spt"/default
# is caught here, distinct from the structural manifest-schema check. (The launcher brand is
# decoupled from the plugin name `sptc`; the s/sptc/spt/ succession renames the plugin, not this.)
# Run: sh tests/manifest-shortcut.sh (exit 0 = pass).
# [unit->REQ-DIST-SHORTCUT-BASENAME]
ROOT=$(CDPATH= cd "$(dirname "$0")/.." && pwd)
MANIFEST="$ROOT/adapter/claude-spt.toml"
fail=0

# The active assignment line (not a comment): a TOML key at column 0. grep -E, exclude `#`-leading.
line=$(grep -E '^[[:space:]]*shortcut_basename[[:space:]]*=' "$MANIFEST")
if [ -z "$line" ]; then echo "FAIL no shortcut_basename assignment in manifest"; exit 1; fi

case "$line" in
  *'"cc"'*) echo "ok   shortcut_basename = \"cc\" (cc-<id> brand intact)" ;;
  *) echo "FAIL shortcut_basename is not \"cc\": $line"; fail=1 ;;
esac

# Defensive: exactly one active assignment (a stray second would make the picker value ambiguous).
n=$(grep -Ec '^[[:space:]]*shortcut_basename[[:space:]]*=' "$MANIFEST")
if [ "$n" -eq 1 ]; then echo "ok   single shortcut_basename assignment"; else echo "FAIL $n shortcut_basename assignments (want 1)"; fail=1; fi

# [unit->REQ-HAZARD-PSYCHE-PERMS-DEADLOCK]
# Non-interactive bringup: BOTH [session.self] CC spawn commands (base `claude`, the `ccs` profile)
# MUST carry --dangerously-skip-permissions. The broker spawns CC into a PTY with no operator, so an
# interactive permission prompt would deadlock the launch (docs/KNOWN-HAZARDS.md §2.2). Match the
# active `command =` lines whose value is a bare claude/ccs CC spawn — NOT `claude-spt-psyche` (the
# runner, whose own turns are asserted in tools/claude-spt-psyche unit tests).
spawn=$(grep -E '^[[:space:]]*command[[:space:]]*=[[:space:]]*"(claude|ccs)([[:space:]]|")' "$MANIFEST")
nspawn=$(printf '%s' "$spawn" | grep -c .)
if [ "$nspawn" -eq 2 ]; then echo "ok   2 [session.self] CC spawn commands (base claude + ccs)"; else echo "FAIL expected 2 CC spawn commands, found $nspawn"; fail=1; fi
nomiss=$(printf '%s\n' "$spawn" | grep -c -v -- '--dangerously-skip-permissions')
if [ "$nspawn" -gt 0 ] && [ "$nomiss" -eq 0 ]; then echo "ok   every CC spawn command carries --dangerously-skip-permissions"; else echo "FAIL a CC spawn command lacks --dangerously-skip-permissions ($nomiss without it)"; fail=1; fi

[ "$fail" -eq 0 ] && { echo "MANIFEST-SHORTCUT OK"; exit 0; } || { echo "MANIFEST-SHORTCUT FAIL"; exit 1; }

-# Non-interactive bringup: BOTH [session.self] CC spawn commands (base `claude`, the `ccs` profile)

-# MUST carry --dangerously-skip-permissions. The broker spawns CC into a PTY with no operator, so an

-# interactive permission prompt would deadlock the launch (docs/KNOWN-HAZARDS.md §2.2). Match the

-# active `command =` lines whose value is a bare claude/ccs CC spawn — NOT `claude-spt-psyche` (the

-# runner, whose own turns are asserted in tools/claude-spt-psyche unit tests).

+# Non-interactive bringup: ALL THREE CC spawn commands — the two [session.self] (base `claude`, the

+# `ccs` profile) AND [session.resume] (`claude -r … --remote-control …`) — MUST carry

+# --dangerously-skip-permissions. The broker spawns CC into a PTY with no operator, so an interactive

+# permission prompt would deadlock the launch (docs/KNOWN-HAZARDS.md §2.2). Match the active

+# `command =` lines whose value is a bare claude/ccs CC spawn — NOT `claude-spt-psyche` (the runner,

+# whose own turns are asserted in tools/claude-spt-psyche unit tests).

-if [ "$nspawn" -eq 2 ]; then echo "ok   2 [session.self] CC spawn commands (base claude + ccs)"; else echo "FAIL expected 2 CC spawn commands, found $nspawn"; fail=1; fi

+if [ "$nspawn" -eq 3 ]; then echo "ok   3 CC spawn commands ([session.self] base claude + ccs, [session.resume])"; else echo "FAIL expected 3 CC spawn commands, found $nspawn"; fail=1; fi


Add manifest-side unit assertions for both new seams to the same test.


nomiss=$(printf '%s\n' "$spawn" | grep -c -v -- '--dangerously-skip-permissions')
if [ "$nspawn" -gt 0 ] && [ "$nomiss" -eq 0 ]; then echo "ok   every CC spawn command carries --dangerously-skip-permissions"; else echo "FAIL a CC spawn command lacks --dangerously-skip-permissions ($nomiss without it)"; fail=1; fi

[ "$fail" -eq 0 ] && { echo "MANIFEST-SHORTCUT OK"; exit 0; } || { echo "MANIFEST-SHORTCUT FAIL"; exit 1; }

nomiss=$(printf '%s\n' "$spawn" | grep -c -v -- '--dangerously-skip-permissions')
if [ "$nspawn" -gt 0 ] && [ "$nomiss" -eq 0 ]; then echo "ok   every CC spawn command carries --dangerously-skip-permissions"; else echo "FAIL a CC spawn command lacks --dangerously-skip-permissions ($nomiss without it)"; fail=1; fi

# [unit->REQ-DIST-SESSION-RESUME]
# [session.resume] declares CC's native-resume verb — `-r {session_id}` (reload the real transcript,
# else a resume re-runs [session.self] → blank) and `--remote-control {id}` (thread the endpoint id
# as the RC session name). A drift that drops either fill silently breaks native resume / RC drive.
resume=$(grep -E '^[[:space:]]*command[[:space:]]*=[[:space:]]*"claude -r ' "$MANIFEST")
if [ -z "$resume" ]; then echo "FAIL no [session.resume] `claude -r …` command in manifest"; fail=1; else
  case "$resume" in
    *'-r {session_id}'*) echo "ok   [session.resume] reloads by {session_id} (-r)";;
    *) echo "FAIL [session.resume] missing -r {session_id}: $resume"; fail=1;;
  esac
  case "$resume" in
    *'--remote-control {id}'*) echo "ok   [session.resume] threads {id} as the remote-control name";;
    *) echo "FAIL [session.resume] missing --remote-control {id}: $resume"; fail=1;;
  esac
fi

# [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

[ "$fail" -eq 0 ] && { echo "MANIFEST-SHORTCUT OK"; exit 0; } || { echo "MANIFEST-SHORTCUT FAIL"; exit 1; }


nomiss=$(printf '%s\n' "$spawn" | grep -c -v -- '--dangerously-skip-permissions')
if [ "$nspawn" -gt 0 ] && [ "$nomiss" -eq 0 ]; then echo "ok   every CC spawn command carries --dangerously-skip-permissions"; else echo "FAIL a CC spawn command lacks --dangerously-skip-permissions ($nomiss without it)"; fail=1; fi

# [unit->REQ-DIST-SESSION-RESUME]
# [session.resume] declares CC's native-resume verb — `-r {session_id}` (reload the real transcript,
# else a resume re-runs [session.self] → blank) and `--remote-control {id}` (thread the endpoint id
# as the RC session name). A drift that drops either fill silently breaks native resume / RC drive.
resume=$(grep -E '^[[:space:]]*command[[:space:]]*=[[:space:]]*"claude -r ' "$MANIFEST")
if [ -z "$resume" ]; then echo "FAIL no [session.resume] `claude -r …` command in manifest"; fail=1; else
  case "$resume" in
    *'-r {session_id}'*) echo "ok   [session.resume] reloads by {session_id} (-r)";;
    *) echo "FAIL [session.resume] missing -r {session_id}: $resume"; fail=1;;
  esac
  case "$resume" in
    *'--remote-control {id}'*) echo "ok   [session.resume] threads {id} as the remote-control name";;
    *) echo "FAIL [session.resume] missing --remote-control {id}: $resume"; fail=1;;
  esac
fi

# [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

[ "$fail" -eq 0 ] && { echo "MANIFEST-SHORTCUT OK"; exit 0; } || { echo "MANIFEST-SHORTCUT FAIL"; exit 1; }

#!/bin/sh
# Brand-value regression guard for the `spt endpoint run` shortcut: the manifest MUST declare
# adapter.shortcut_basename = "cc" (→ cc-<id>, the M12 cc launcher). A drift to "sptc"/"spt"/default
# is caught here, distinct from the structural manifest-schema check. (The launcher brand is
# decoupled from the plugin name `sptc`; the s/sptc/spt/ succession renames the plugin, not this.)
# Run: sh tests/manifest-shortcut.sh (exit 0 = pass).
# [unit->REQ-DIST-SHORTCUT-BASENAME]
ROOT=$(CDPATH= cd "$(dirname "$0")/.." && pwd)
MANIFEST="$ROOT/adapter/claude-spt.toml"
fail=0

# The active assignment line (not a comment): a TOML key at column 0. grep -E, exclude `#`-leading.
line=$(grep -E '^[[:space:]]*shortcut_basename[[:space:]]*=' "$MANIFEST")
if [ -z "$line" ]; then echo "FAIL no shortcut_basename assignment in manifest"; exit 1; fi

case "$line" in
  *'"cc"'*) echo "ok   shortcut_basename = \"cc\" (cc-<id> brand intact)" ;;
  *) echo "FAIL shortcut_basename is not \"cc\": $line"; fail=1 ;;
esac

# Defensive: exactly one active assignment (a stray second would make the picker value ambiguous).
n=$(grep -Ec '^[[:space:]]*shortcut_basename[[:space:]]*=' "$MANIFEST")
if [ "$n" -eq 1 ]; then echo "ok   single shortcut_basename assignment"; else echo "FAIL $n shortcut_basename assignments (want 1)"; fail=1; fi

# [unit->REQ-HAZARD-PSYCHE-PERMS-DEADLOCK]
# Non-interactive bringup: ALL THREE CC spawn commands — the two [session.self] (base `claude`, the
# `ccs` profile) AND [session.resume] (`claude -r … --remote-control …`) — MUST carry
# --dangerously-skip-permissions. The broker spawns CC into a PTY with no operator, so an interactive
# permission prompt would deadlock the launch (docs/KNOWN-HAZARDS.md §2.2). Match the active
# `command =` lines whose value is a bare claude/ccs CC spawn — NOT `claude-spt-psyche` (the runner,
# whose own turns are asserted in tools/claude-spt-psyche unit tests).
spawn=$(grep -E '^[[:space:]]*command[[:space:]]*=[[:space:]]*"(claude|ccs)([[:space:]]|")' "$MANIFEST")
nspawn=$(printf '%s' "$spawn" | grep -c .)
if [ "$nspawn" -eq 3 ]; then echo "ok   3 CC spawn commands ([session.self] base claude + ccs, [session.resume])"; else echo "FAIL expected 3 CC spawn commands, found $nspawn"; fail=1; fi
nomiss=$(printf '%s\n' "$spawn" | grep -c -v -- '--dangerously-skip-permissions')
if [ "$nspawn" -gt 0 ] && [ "$nomiss" -eq 0 ]; then echo "ok   every CC spawn command carries --dangerously-skip-permissions"; else echo "FAIL a CC spawn command lacks --dangerously-skip-permissions ($nomiss without it)"; fail=1; fi

[ "$fail" -eq 0 ] && { echo "MANIFEST-SHORTCUT OK"; exit 0; } || { echo "MANIFEST-SHORTCUT FAIL"; exit 1; }

+# [session.resume] declares CC's native-resume verb — `-r {session_id}` (reload the real transcript,

+# else a resume re-runs [session.self] → blank) and `--remote-control {id}` (thread the endpoint id

+# as the RC session name). A drift that drops either fill silently breaks native resume / RC drive.

+resume=$(grep -E '^[[:space:]]*command[[:space:]]*=[[:space:]]*"claude -r ' "$MANIFEST")

+if [ -z "$resume" ]; then echo "FAIL no [session.resume] `claude -r …` command in manifest"; fail=1; else

+    *'-r {session_id}'*) echo "ok   [session.resume] reloads by {session_id} (-r)";;

+    *) echo "FAIL [session.resume] missing -r {session_id}: $resume"; fail=1;;

+    *'--remote-control {id}'*) echo "ok   [session.resume] threads {id} as the remote-control name";;

+    *) echo "FAIL [session.resume] missing --remote-control {id}: $resume"; fail=1;;

+# [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


Backtick bug — inside double quotes `sh` will command-substitute `` `claude -r …` ``. Fix it.


if [ -z "$resume" ]; then echo "FAIL no [session.resume] `claude -r …` command in manifest"; fail=1; else

if [ -z "$resume" ]; then echo 'FAIL no [session.resume] claude -r … command in manifest'; fail=1; else


#!/bin/sh
# Brand-value regression guard for the `spt endpoint run` shortcut: the manifest MUST declare
# adapter.shortcut_basename = "cc" (→ cc-<id>, the M12 cc launcher). A drift to "sptc"/"spt"/default
# is caught here, distinct from the structural manifest-schema check. (The launcher brand is
# decoupled from the plugin name `sptc`; the s/sptc/spt/ succession renames the plugin, not this.)
# Run: sh tests/manifest-shortcut.sh (exit 0 = pass).
# [unit->REQ-DIST-SHORTCUT-BASENAME]
ROOT=$(CDPATH= cd "$(dirname "$0")/.." && pwd)
MANIFEST="$ROOT/adapter/claude-spt.toml"
fail=0

# The active assignment line (not a comment): a TOML key at column 0. grep -E, exclude `#`-leading.
line=$(grep -E '^[[:space:]]*shortcut_basename[[:space:]]*=' "$MANIFEST")
if [ -z "$line" ]; then echo "FAIL no shortcut_basename assignment in manifest"; exit 1; fi

case "$line" in
  *'"cc"'*) echo "ok   shortcut_basename = \"cc\" (cc-<id> brand intact)" ;;
  *) echo "FAIL shortcut_basename is not \"cc\": $line"; fail=1 ;;
esac

# Defensive: exactly one active assignment (a stray second would make the picker value ambiguous).
n=$(grep -Ec '^[[:space:]]*shortcut_basename[[:space:]]*=' "$MANIFEST")
if [ "$n" -eq 1 ]; then echo "ok   single shortcut_basename assignment"; else echo "FAIL $n shortcut_basename assignments (want 1)"; fail=1; fi

# [unit->REQ-HAZARD-PSYCHE-PERMS-DEADLOCK]
# Non-interactive bringup: ALL THREE CC spawn commands — the two [session.self] (base `claude`, the
# `ccs` profile) AND [session.resume] (`claude -r … --remote-control …`) — MUST carry
# --dangerously-skip-permissions. The broker spawns CC into a PTY with no operator, so an interactive
# permission prompt would deadlock the launch (docs/KNOWN-HAZARDS.md §2.2). Match the active
# `command =` lines whose value is a bare claude/ccs CC spawn — NOT `claude-spt-psyche` (the runner,
# whose own turns are asserted in tools/claude-spt-psyche unit tests).
spawn=$(grep -E '^[[:space:]]*command[[:space:]]*=[[:space:]]*"(claude|ccs)([[:space:]]|")' "$MANIFEST")
nspawn=$(printf '%s' "$spawn" | grep -c .)
if [ "$nspawn" -eq 3 ]; then echo "ok   3 CC spawn commands ([session.self] base claude + ccs, [session.resume])"; else echo "FAIL expected 3 CC spawn commands, found $nspawn"; fail=1; fi
nomiss=$(printf '%s\n' "$spawn" | grep -c -v -- '--dangerously-skip-permissions')
if [ "$nspawn" -gt 0 ] && [ "$nomiss" -eq 0 ]; then echo "ok   every CC spawn command carries --dangerously-skip-permissions"; else echo "FAIL a CC spawn command lacks --dangerously-skip-permissions ($nomiss without it)"; fail=1; fi

# [unit->REQ-DIST-SESSION-RESUME]
# [session.resume] declares CC's native-resume verb — `-r {session_id}` (reload the real transcript,
# else a resume re-runs [session.self] → blank) and `--remote-control {id}` (thread the endpoint id
# as the RC session name). A drift that drops either fill silently breaks native resume / RC drive.
resume=$(grep -E '^[[:space:]]*command[[:space:]]*=[[:space:]]*"claude -r ' "$MANIFEST")
if [ -z "$resume" ]; then echo "FAIL no [session.resume] `claude -r …` command in manifest"; fail=1; else
  case "$resume" in
    *'-r {session_id}'*) echo "ok   [session.resume] reloads by {session_id} (-r)";;
    *) echo "FAIL [session.resume] missing -r {session_id}: $resume"; fail=1;;
  esac
  case "$resume" in
    *'--remote-control {id}'*) echo "ok   [session.resume] threads {id} as the remote-control name";;
    *) echo "FAIL [session.resume] missing --remote-control {id}: $resume"; fail=1;;
  esac
fi

# [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

[ "$fail" -eq 0 ] && { echo "MANIFEST-SHORTCUT OK"; exit 0; } || { echo "MANIFEST-SHORTCUT FAIL"; exit 1; }

-if [ -z "$resume" ]; then echo "FAIL no [session.resume] `claude -r …` command in manifest"; fail=1; else

+if [ -z "$resume" ]; then echo 'FAIL no [session.resume] claude -r … command in manifest'; fail=1; else


Bash

cd "C:/Users/decid/Documents/projects/spt-claude-code" && sh tests/manifest-shortcut.sh; echo "EXIT=$?"

Run manifest-shortcut test


ok   shortcut_basename = "cc" (cc-<id> brand intact)
ok   single shortcut_basename assignment
ok   3 CC spawn commands ([session.self] base claude + ccs, [session.resume])
ok   every CC spawn command carries --dangerously-skip-permissions
ok   [session.resume] reloads by {session_id} (-r)
ok   [session.resume] threads {id} as the remote-control name
ok   [message-idle-translation-binary] path = "cc-spt-idle-translate"
MANIFEST-SHORTCUT OK
EXIT=0


Manifest test green. Now the CI build gate for the new crate + wire into run-gates.


Write

#!/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"


File created successfully at: C:/Users/decid/Documents/projects/spt-claude-code/ci/idle-translate/build.sh (file state is current in your context — no need to Read it back)

#!/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"


Edit

gate "psyche-runner (cargo build + test)"
if [ -f "$ROOT/ci/psyche/build.sh" ]; then
  if sh "$ROOT/ci/psyche/build.sh"; then :; else fail "psyche-runner"; fi
else
  echo "SKIP: no psyche runner build yet"
fi

gate "psyche-runner (cargo build + test)"
if [ -f "$ROOT/ci/psyche/build.sh" ]; then
  if sh "$ROOT/ci/psyche/build.sh"; then :; else fail "psyche-runner"; fi
else
  echo "SKIP: no psyche runner build yet"
fi

gate "idle-translate (cargo build + test)"
if [ -f "$ROOT/ci/idle-translate/build.sh" ]; then
  if sh "$ROOT/ci/idle-translate/build.sh"; then :; else fail "idle-translate"; fi
else
  echo "SKIP: no idle-translate build yet"
fi


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

gate "psyche-runner (cargo build + test)"
if [ -f "$ROOT/ci/psyche/build.sh" ]; then
  if sh "$ROOT/ci/psyche/build.sh"; then :; else fail "psyche-runner"; fi
else
  echo "SKIP: no psyche runner build yet"
fi

gate "idle-translate (cargo build + test)"
if [ -f "$ROOT/ci/idle-translate/build.sh" ]; then
  if sh "$ROOT/ci/idle-translate/build.sh"; then :; else fail "idle-translate"; fi
else
  echo "SKIP: no idle-translate build yet"
fi

#!/bin/sh
# Deterministic CI gates for spt-claude-code — each a binary pass/fail, no model in the loop.
# Runnable by hand on any fleet host: this IS the manual fallback. [impl->REQ-CI-GATES]
# [impl->REQ-CI-MANUAL]
# Gates whose artifact doesn't exist yet SKIP with a logged note (no silent caps — a skip is
# always announced so "green" never hides un-run coverage).
set -u
ROOT=$(CDPATH= cd "$(dirname "$0")/.." && pwd)
cd "$ROOT" || exit 2
rc=0
gate() { printf '\n=== GATE: %s ===\n' "$1"; }
fail() { printf 'FAIL: %s\n' "$1"; rc=1; }

gate "shell-syntax (sh -n)"
for f in $(find plugin tests ci -name '*.sh' 2>/dev/null | sort); do
  if sh -n "$f"; then printf 'ok  %s\n' "$f"; else fail "syntax: $f"; fi
done

gate "unit tests (tests/*.sh)"
for t in tests/*.sh; do
  [ -e "$t" ] || continue
  if sh "$t" >/dev/null 2>&1; then printf 'ok  %s\n' "$t"; else fail "unit: $t"; sh "$t" || true; fi
done

gate "digest-extractor (cargo build + test)"
if [ -f "$ROOT/ci/digest/build.sh" ]; then
  if sh "$ROOT/ci/digest/build.sh"; then :; else fail "digest-extractor"; fi
else
  echo "SKIP: no digest extractor build yet"
fi

gate "psyche-runner (cargo build + test)"
if [ -f "$ROOT/ci/psyche/build.sh" ]; then
  if sh "$ROOT/ci/psyche/build.sh"; then :; else fail "psyche-runner"; fi
else
  echo "SKIP: no psyche runner build yet"
fi

gate "traceable-reqs check (requirement coverage)"
if command -v traceable-reqs >/dev/null 2>&1; then
  if traceable-reqs check >/dev/null 2>&1; then echo "ok  coverage green"; else fail "traceable-reqs check"; traceable-reqs check || true; fi
else
  echo "SKIP: traceable-reqs not on PATH (install per docs/TRACEABILITY.md)"
fi

gate "skeleton-validate (cplugs installability)"
if [ -x "$ROOT/ci/publish/validate-skeleton.sh" ] || [ -f "$ROOT/ci/publish/validate-skeleton.sh" ]; then
  if sh "$ROOT/ci/publish/validate-skeleton.sh" >/dev/null 2>&1; then echo "ok  skeleton installable"; else fail "skeleton-validate"; sh "$ROOT/ci/publish/validate-skeleton.sh" || true; fi
else
  echo "SKIP: no skeleton validator yet"
fi

gate "manifest-schema"
if [ -f "$ROOT/ci/manifest/check-manifest.sh" ]; then
  if sh "$ROOT/ci/manifest/check-manifest.sh"; then :; else fail "manifest-schema"; fi
else
  echo "SKIP: no adapter manifest yet — activates when the CC adapter manifest lands"
fi

gate "docs-drift"
if [ -f "$ROOT/ci/docs/check-docs.sh" ]; then
  if sh "$ROOT/ci/docs/check-docs.sh"; then :; else fail "docs-drift"; fi
else
  echo "SKIP: no docs check yet"
fi

printf '\n=== RESULT: %s ===\n' "$([ "$rc" -eq 0 ] && echo PASS || echo FAIL)"
exit "$rc"

+gate "idle-translate (cargo build + test)"

+if [ -f "$ROOT/ci/idle-translate/build.sh" ]; then

+  if sh "$ROOT/ci/idle-translate/build.sh"; then :; else fail "idle-translate"; fi

+  echo "SKIP: no idle-translate build yet"


Now check the adapter packer — does it auto-include the new binary, or is the binary list hardcoded? (`adapter.spt` must carry `cc-spt-idle-translate` for spt-core to resolve `path=`.)


Bash

cd "C:/Users/decid/Documents/projects/spt-claude-code" && grep -nE "digest|psyche|cc-spt|binar|BIN|tools/|\.exe|release" ci/publish/package-adapter.sh | head -40

Inspect packer binary enumeration


2:# Pack the `adapter.spt` release asset — the end-user distribution acquired by
3:# `spt adapter add --release SaberMage/spt-claude-code` (doyle's --release source, spt v0.7.3+).
4:# The archive ROOT must hold `manifest.toml` (named EXACTLY that) + `strings/` + the tool binaries;
5:# spt-core fetches it from the repo's GitHub release, extracts to the durable home, registers the
6:# root. DRY-RUN by default. Never uploads — the release/tag/upload is the operator's step.
21:# PER-OS asset name: the .spt carries native binaries, so it is platform-specific and ships as
36:# Native executable suffix, keyed off the DECLARED os (windows → .exe, else bare) — the archive
37:# carries the binary under its native name so a bare-name template (`claude-spt-digest …`) resolves.
38:case "$SPTC_OS" in windows) EXE=".exe" ;; *) EXE="" ;; esac
40:# the binaries land under target/<triple>/release; a native build uses target/release.
41:if [ -n "${SPTC_TARGET:-}" ]; then RELSUB="$SPTC_TARGET/release"; else RELSUB="release"; fi
42:DIGEST="$ROOT/tools/claude-spt-digest/target/$RELSUB/claude-spt-digest$EXE"
43:PSYCHE="$ROOT/tools/claude-spt-psyche/target/$RELSUB/claude-spt-psyche$EXE"
54:# Require the release binaries (NOT auto-copied by register — they must ride the archive; doyle).
56:echo "== tool binaries =="
62:  echo "REFUSING to package: build the release binaries first — sh ci/digest/build.sh && sh ci/psyche/build.sh" >&2
70:echo "binaries : claude-spt-digest$EXE, claude-spt-psyche$EXE  ->  (archive root)"
73:echo "NOTE: the archive is PLATFORM-SPECIFIC (native binaries: '$EXE' build) — hence the per-OS name."
74:echo "      Ship one asset per OS on the same release; /sptc:setup picks the host's via --asset. Build"
77:echo "      resolves the bundled binaries from the install dir (no PATH copy); pre-0.8.0 they need PATH."
82:  echo "release on the monorepo. End users acquire it with 'spt adapter add --release"
92:cp "$DIGEST" "$STAGE/claude-spt-digest$EXE"
93:cp "$PSYCHE" "$STAGE/claude-spt-psyche$EXE"
96:( cd "$STAGE" && tar -czf "$OUT" manifest.toml strings claude-spt-digest$EXE claude-spt-psyche$EXE ) \
105:  echo "FATAL: manifest.toml is not at the archive root — adapter add --release would reject it" >&2
110:echo "WROTE $OUT. Next (operator): attach to a GitHub release on SaberMage/spt-claude-code (alongside"
111:echo "the other per-OS assets), then end users 'spt adapter add --release SaberMage/spt-claude-code"


Packer hardcodes the binary list — must add the third. Read the relevant span.


40	# the binaries land under target/<triple>/release; a native build uses target/release.
41	if [ -n "${SPTC_TARGET:-}" ]; then RELSUB="$SPTC_TARGET/release"; else RELSUB="release"; fi
42	DIGEST="$ROOT/tools/claude-spt-digest/target/$RELSUB/claude-spt-digest$EXE"
43	PSYCHE="$ROOT/tools/claude-spt-psyche/target/$RELSUB/claude-spt-psyche$EXE"
44	ASSET="adapter-${SPTC_OS}-${SPTC_ARCH}.spt"
45	OUT="${ADAPTER_SPT_OUT:-$ROOT/dist/$ASSET}"   # overridable so the unit test writes to a tmp file
46
47	# Validate the manifest first — refuse to ship an invalid adapter.
48	echo "== validate manifest =="
49	if ! sh "$ROOT/ci/manifest/check-manifest.sh"; then
50	  echo "REFUSING to package: manifest failed schema validation (fix above, then retry)." >&2
51	  exit 1
52	fi
53
54	# Require the release binaries (NOT auto-copied by register — they must ride the archive; doyle).
55	echo
56	echo "== tool binaries =="
57	missing=0
58	for b in "$DIGEST" "$PSYCHE"; do
59	  if [ -f "$b" ]; then echo "  ok    $b"; else echo "  MISS  $b"; missing=1; fi
60	done
61	if [ "$missing" -ne 0 ]; then
62	  echo "REFUSING to package: build the release binaries first — sh ci/digest/build.sh && sh ci/psyche/build.sh" >&2
63	  exit 1
64	fi
65
66	echo
67	echo "== plan ($([ "$APPLY" -eq 1 ] && echo APPLY || echo DRY-RUN)) =="
68	echo "manifest : $MANIFEST  ->  (archive root) manifest.toml"
69	echo "strings  : $STRINGS/  ->  (archive root) strings/"
70	echo "binaries : claude-spt-digest$EXE, claude-spt-psyche$EXE  ->  (archive root)"
71	echo "os/arch  : $SPTC_OS/$SPTC_ARCH"
72	echo "asset    : $OUT  (-> $ASSET)"
73	echo "NOTE: the archive is PLATFORM-SPECIFIC (native binaries: '$EXE' build) — hence the per-OS name."
74	echo "      Ship one asset per OS on the same release; /sptc:setup picks the host's via --asset. Build"
75	echo "      each OS native, OR cross-build Linux from Windows via cargo-zigbuild + SPTC_TARGET (bare"
76	echo "      'cargo build --target' fails 'cc not found'; zig provides the linker). On v0.8.0+ Feature B"
77	echo "      resolves the bundled binaries from the install dir (no PATH copy); pre-0.8.0 they need PATH."
78
79	if [ "$APPLY" -ne 1 ]; then
80	  echo
81	  echo "DRY-RUN: nothing written. Re-run with --apply to write $OUT, then attach it to a GitHub"
82	  echo "release on the monorepo. End users acquire it with 'spt adapter add --release"
83	  echo "SaberMage/spt-claude-code --asset $ASSET' (see docs/RELEASE-RUNBOOK.md). Needs spt v0.7.3+."
84	  exit 0
85	fi
86
87	# Stage the archive ROOT in a temp dir, then tar from it so paths are root-relative (no leading dirs).
88	STAGE=$(mktemp -d "${TMPDIR:-/tmp}/sptc-adapter.XXXXXX") || { echo "FATAL: mktemp failed"; exit 2; }
89	trap 'rm -rf "$STAGE"' EXIT
90	cp "$MANIFEST" "$STAGE/manifest.toml"
91	cp -r "$STRINGS" "$STAGE/strings"
92	cp "$DIGEST" "$STAGE/claude-spt-digest$EXE"
93	cp "$PSYCHE" "$STAGE/claude-spt-psyche$EXE"
94
95	mkdir -p "$(dirname "$OUT")" || { echo "FATAL: cannot create $(dirname "$OUT")"; exit 2; }
96	( cd "$STAGE" && tar -czf "$OUT" manifest.toml strings claude-spt-digest$EXE claude-spt-psyche$EXE ) \
97	  || { echo "FATAL: tar failed"; exit 2; }
98
99	# Self-validate the produced archive: manifest.toml must be at the ROOT (no leading path component).

# the binaries land under target/<triple>/release; a native build uses target/release.
if [ -n "${SPTC_TARGET:-}" ]; then RELSUB="$SPTC_TARGET/release"; else RELSUB="release"; fi
DIGEST="$ROOT/tools/claude-spt-digest/target/$RELSUB/claude-spt-digest$EXE"
PSYCHE="$ROOT/tools/claude-spt-psyche/target/$RELSUB/claude-spt-psyche$EXE"
ASSET="adapter-${SPTC_OS}-${SPTC_ARCH}.spt"
OUT="${ADAPTER_SPT_OUT:-$ROOT/dist/$ASSET}"   # overridable so the unit test writes to a tmp file

# 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

# Require the release binaries (NOT auto-copied by register — they must ride the archive; doyle).
echo
echo "== tool binaries =="
missing=0
for b in "$DIGEST" "$PSYCHE"; do
  if [ -f "$b" ]; then echo "  ok    $b"; else echo "  MISS  $b"; missing=1; fi
done
if [ "$missing" -ne 0 ]; then
  echo "REFUSING to package: build the release binaries first — sh ci/digest/build.sh && sh ci/psyche/build.sh" >&2
  exit 1
fi

echo
echo "== plan ($([ "$APPLY" -eq 1 ] && echo APPLY || echo DRY-RUN)) =="
echo "manifest : $MANIFEST  ->  (archive root) manifest.toml"
echo "strings  : $STRINGS/  ->  (archive root) strings/"
echo "binaries : claude-spt-digest$EXE, claude-spt-psyche$EXE  ->  (archive root)"
echo "os/arch  : $SPTC_OS/$SPTC_ARCH"
echo "asset    : $OUT  (-> $ASSET)"
echo "NOTE: the archive is PLATFORM-SPECIFIC (native binaries: '$EXE' build) — hence the per-OS name."
echo "      Ship one asset per OS on the same release; /sptc:setup picks the host's via --asset. Build"
echo "      each OS native, OR cross-build Linux from Windows via cargo-zigbuild + SPTC_TARGET (bare"
echo "      'cargo build --target' fails 'cc not found'; zig provides the linker). On v0.8.0+ Feature B"
echo "      resolves the bundled binaries from the install dir (no PATH copy); pre-0.8.0 they need PATH."

if [ "$APPLY" -ne 1 ]; then
  echo
  echo "DRY-RUN: nothing written. Re-run with --apply to write $OUT, then attach it to a GitHub"
  echo "release on the monorepo. End users acquire it with 'spt adapter add --release"
  echo "SaberMage/spt-claude-code --asset $ASSET' (see docs/RELEASE-RUNBOOK.md). Needs spt v0.7.3+."
  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"
cp "$DIGEST" "$STAGE/claude-spt-digest$EXE"
cp "$PSYCHE" "$STAGE/claude-spt-psyche$EXE"

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

# Self-validate the produced archive: manifest.toml must be at the ROOT (no leading path component).


Add the third binary through the packer (var, check, plan line, cp, tar root).


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)

#!/bin/sh
# Pack the `adapter.spt` release asset — the end-user distribution acquired by
# `spt adapter add --release SaberMage/spt-claude-code` (doyle's --release source, spt v0.7.3+).
# The archive ROOT must hold `manifest.toml` (named EXACTLY that) + `strings/` + the tool binaries;
# spt-core fetches it from the repo's GitHub release, extracts to the durable home, registers the
# root. DRY-RUN by default. Never uploads — the release/tag/upload is the operator's step.
# [impl->REQ-DIST-ADAPTER-RELEASE]
#
# 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"
APPLY=0
[ "${1:-}" = "--apply" ] && APPLY=1

# PER-OS asset name: the .spt carries native binaries, so it is platform-specific and ships as
# `adapter-<os>-<arch>.spt`. /sptc:setup acquires the matching one via spt's caller-named `--asset`.
# Host-derived from uname; override SPTC_OS/SPTC_ARCH for a cross build / CI. [impl->REQ-DIST-ADAPTER-PEROS]
SPTC_OS="${SPTC_OS:-}"; SPTC_ARCH="${SPTC_ARCH:-}"
if [ -z "$SPTC_OS" ]; then case "$(uname -s 2>/dev/null)" in
  MINGW*|MSYS*|CYGWIN*|Windows*) SPTC_OS=windows ;;
  Linux) SPTC_OS=linux ;;
  Darwin) SPTC_OS=macos ;;
  *) SPTC_OS=unknown ;;
esac; fi
if [ -z "$SPTC_ARCH" ]; then case "$(uname -m 2>/dev/null)" in
  x86_64|amd64) SPTC_ARCH=x86_64 ;;
  arm64|aarch64) SPTC_ARCH=aarch64 ;;
  *) SPTC_ARCH="$(uname -m 2>/dev/null || echo unknown)" ;;
esac; fi
# Native executable suffix, keyed off the DECLARED os (windows → .exe, else bare) — the archive
# carries the binary under its native name so a bare-name template (`claude-spt-digest …`) resolves.
case "$SPTC_OS" in windows) EXE=".exe" ;; *) EXE="" ;; esac
# Binary location: a cross build sets SPTC_TARGET (e.g. x86_64-unknown-linux-gnu → cargo-zigbuild) and
# the binaries land under target/<triple>/release; a native build uses target/release.
if [ -n "${SPTC_TARGET:-}" ]; then RELSUB="$SPTC_TARGET/release"; else RELSUB="release"; fi
DIGEST="$ROOT/tools/claude-spt-digest/target/$RELSUB/claude-spt-digest$EXE"
PSYCHE="$ROOT/tools/claude-spt-psyche/target/$RELSUB/claude-spt-psyche$EXE"
ASSET="adapter-${SPTC_OS}-${SPTC_ARCH}.spt"
OUT="${ADAPTER_SPT_OUT:-$ROOT/dist/$ASSET}"   # overridable so the unit test writes to a tmp file

# 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

# Require the release binaries (NOT auto-copied by register — they must ride the archive; doyle).
echo
echo "== tool binaries =="
missing=0
for b in "$DIGEST" "$PSYCHE"; do
  if [ -f "$b" ]; then echo "  ok    $b"; else echo "  MISS  $b"; missing=1; fi
done
if [ "$missing" -ne 0 ]; then
  echo "REFUSING to package: build the release binaries first — sh ci/digest/build.sh && sh ci/psyche/build.sh" >&2
  exit 1
fi

echo
echo "== plan ($([ "$APPLY" -eq 1 ] && echo APPLY || echo DRY-RUN)) =="
echo "manifest : $MANIFEST  ->  (archive root) manifest.toml"
echo "strings  : $STRINGS/  ->  (archive root) strings/"
echo "binaries : claude-spt-digest$EXE, claude-spt-psyche$EXE  ->  (archive root)"
echo "os/arch  : $SPTC_OS/$SPTC_ARCH"
echo "asset    : $OUT  (-> $ASSET)"
echo "NOTE: the archive is PLATFORM-SPECIFIC (native binaries: '$EXE' build) — hence the per-OS name."
echo "      Ship one asset per OS on the same release; /sptc:setup picks the host's via --asset. Build"
echo "      each OS native, OR cross-build Linux from Windows via cargo-zigbuild + SPTC_TARGET (bare"
echo "      'cargo build --target' fails 'cc not found'; zig provides the linker). On v0.8.0+ Feature B"
echo "      resolves the bundled binaries from the install dir (no PATH copy); pre-0.8.0 they need PATH."

if [ "$APPLY" -ne 1 ]; then
  echo
  echo "DRY-RUN: nothing written. Re-run with --apply to write $OUT, then attach it to a GitHub"
  echo "release on the monorepo. End users acquire it with 'spt adapter add --release"
  echo "SaberMage/spt-claude-code --asset $ASSET' (see docs/RELEASE-RUNBOOK.md). Needs spt v0.7.3+."
  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"
cp "$DIGEST" "$STAGE/claude-spt-digest$EXE"
cp "$PSYCHE" "$STAGE/claude-spt-psyche$EXE"

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

# Self-validate the produced archive: manifest.toml must be at the ROOT (no leading path component).
echo
echo "== validate archive =="
if tar -tzf "$OUT" | grep -qx "manifest.toml"; then
  echo "ok   manifest.toml at archive root"
else
  echo "FATAL: manifest.toml is not at the archive root — adapter add --release would reject it" >&2
  exit 1
fi

echo
echo "WROTE $OUT. Next (operator): attach to a GitHub release on SaberMage/spt-claude-code (alongside"
echo "the other per-OS assets), then end users 'spt adapter add --release SaberMage/spt-claude-code"
echo "--asset $ASSET' (needs spt v0.7.3+). /reload as needed."
exit 0


Edit

missing=0
for b in "$DIGEST" "$PSYCHE"; do
  if [ -f "$b" ]; then echo "  ok    $b"; else echo "  MISS  $b"; missing=1; fi
done
if [ "$missing" -ne 0 ]; then
  echo "REFUSING to package: build the release binaries first — sh ci/digest/build.sh && sh ci/psyche/build.sh" >&2
  exit 1
fi

missing=0
for b in "$DIGEST" "$PSYCHE" "$IDLE"; do
  if [ -f "$b" ]; then echo "  ok    $b"; else echo "  MISS  $b"; missing=1; fi
done
if [ "$missing" -ne 0 ]; then
  echo "REFUSING to package: build the release binaries first — sh ci/digest/build.sh && sh ci/psyche/build.sh && sh ci/idle-translate/build.sh" >&2
  exit 1
fi


missing=0
for b in "$DIGEST" "$PSYCHE" "$IDLE"; do
  if [ -f "$b" ]; then echo "  ok    $b"; else echo "  MISS  $b"; missing=1; fi
done
if [ "$missing" -ne 0 ]; then
  echo "REFUSING to package: build the release binaries first — sh ci/digest/build.sh && sh ci/psyche/build.sh && sh ci/idle-translate/build.sh" >&2
  exit 1
fi

#!/bin/sh
# Pack the `adapter.spt` release asset — the end-user distribution acquired by
# `spt adapter add --release SaberMage/spt-claude-code` (doyle's --release source, spt v0.7.3+).
# The archive ROOT must hold `manifest.toml` (named EXACTLY that) + `strings/` + the tool binaries;
# spt-core fetches it from the repo's GitHub release, extracts to the durable home, registers the
# root. DRY-RUN by default. Never uploads — the release/tag/upload is the operator's step.
# [impl->REQ-DIST-ADAPTER-RELEASE]
#
# 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"
APPLY=0
[ "${1:-}" = "--apply" ] && APPLY=1

# PER-OS asset name: the .spt carries native binaries, so it is platform-specific and ships as
# `adapter-<os>-<arch>.spt`. /sptc:setup acquires the matching one via spt's caller-named `--asset`.
# Host-derived from uname; override SPTC_OS/SPTC_ARCH for a cross build / CI. [impl->REQ-DIST-ADAPTER-PEROS]
SPTC_OS="${SPTC_OS:-}"; SPTC_ARCH="${SPTC_ARCH:-}"
if [ -z "$SPTC_OS" ]; then case "$(uname -s 2>/dev/null)" in
  MINGW*|MSYS*|CYGWIN*|Windows*) SPTC_OS=windows ;;
  Linux) SPTC_OS=linux ;;
  Darwin) SPTC_OS=macos ;;
  *) SPTC_OS=unknown ;;
esac; fi
if [ -z "$SPTC_ARCH" ]; then case "$(uname -m 2>/dev/null)" in
  x86_64|amd64) SPTC_ARCH=x86_64 ;;
  arm64|aarch64) SPTC_ARCH=aarch64 ;;
  *) SPTC_ARCH="$(uname -m 2>/dev/null || echo unknown)" ;;
esac; fi
# Native executable suffix, keyed off the DECLARED os (windows → .exe, else bare) — the archive
# carries the binary under its native name so a bare-name template (`claude-spt-digest …`) resolves.
case "$SPTC_OS" in windows) EXE=".exe" ;; *) EXE="" ;; esac
# Binary location: a cross build sets SPTC_TARGET (e.g. x86_64-unknown-linux-gnu → cargo-zigbuild) and
# the binaries land under target/<triple>/release; a native build uses target/release.
if [ -n "${SPTC_TARGET:-}" ]; then RELSUB="$SPTC_TARGET/release"; else RELSUB="release"; fi
DIGEST="$ROOT/tools/claude-spt-digest/target/$RELSUB/claude-spt-digest$EXE"
PSYCHE="$ROOT/tools/claude-spt-psyche/target/$RELSUB/claude-spt-psyche$EXE"
IDLE="$ROOT/tools/cc-spt-idle-translate/target/$RELSUB/cc-spt-idle-translate$EXE"
ASSET="adapter-${SPTC_OS}-${SPTC_ARCH}.spt"
OUT="${ADAPTER_SPT_OUT:-$ROOT/dist/$ASSET}"   # overridable so the unit test writes to a tmp file

# 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

# Require the release binaries (NOT auto-copied by register — they must ride the archive; doyle).
echo
echo "== tool binaries =="
missing=0
for b in "$DIGEST" "$PSYCHE"; do
  if [ -f "$b" ]; then echo "  ok    $b"; else echo "  MISS  $b"; missing=1; fi
done
if [ "$missing" -ne 0 ]; then
  echo "REFUSING to package: build the release binaries first — sh ci/digest/build.sh && sh ci/psyche/build.sh" >&2
  exit 1
fi

echo
echo "== plan ($([ "$APPLY" -eq 1 ] && echo APPLY || echo DRY-RUN)) =="
echo "manifest : $MANIFEST  ->  (archive root) manifest.toml"
echo "strings  : $STRINGS/  ->  (archive root) strings/"
echo "binaries : claude-spt-digest$EXE, claude-spt-psyche$EXE  ->  (archive root)"
echo "os/arch  : $SPTC_OS/$SPTC_ARCH"
echo "asset    : $OUT  (-> $ASSET)"
echo "NOTE: the archive is PLATFORM-SPECIFIC (native binaries: '$EXE' build) — hence the per-OS name."
echo "      Ship one asset per OS on the same release; /sptc:setup picks the host's via --asset. Build"
echo "      each OS native, OR cross-build Linux from Windows via cargo-zigbuild + SPTC_TARGET (bare"
echo "      'cargo build --target' fails 'cc not found'; zig provides the linker). On v0.8.0+ Feature B"
echo "      resolves the bundled binaries from the install dir (no PATH copy); pre-0.8.0 they need PATH."

if [ "$APPLY" -ne 1 ]; then
  echo
  echo "DRY-RUN: nothing written. Re-run with --apply to write $OUT, then attach it to a GitHub"
  echo "release on the monorepo. End users acquire it with 'spt adapter add --release"
  echo "SaberMage/spt-claude-code --asset $ASSET' (see docs/RELEASE-RUNBOOK.md). Needs spt v0.7.3+."
  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"
cp "$DIGEST" "$STAGE/claude-spt-digest$EXE"
cp "$PSYCHE" "$STAGE/claude-spt-psyche$EXE"

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

# Self-validate the produced archive: manifest.toml must be at the ROOT (no leading path component).
echo
echo "== validate archive =="
if tar -tzf "$OUT" | grep -qx "manifest.toml"; then
  echo "ok   manifest.toml at archive root"
else
  echo "FATAL: manifest.toml is not at the archive root — adapter add --release would reject it" >&2
  exit 1
fi

echo
echo "WROTE $OUT. Next (operator): attach to a GitHub release on SaberMage/spt-claude-code (alongside"
echo "the other per-OS assets), then end users 'spt adapter add --release SaberMage/spt-claude-code"
echo "--asset $ASSET' (needs spt v0.7.3+). /reload as needed."
exit 0

+for b in "$DIGEST" "$PSYCHE" "$IDLE"; do

-  echo "REFUSING to package: build the release binaries first — sh ci/digest/build.sh && sh ci/psyche/build.sh" >&2

+  echo "REFUSING to package: build the release binaries first — sh ci/digest/build.sh && sh ci/psyche/build.sh && sh ci/idle-translate/build.sh" >&2


echo "binaries : claude-spt-digest$EXE, claude-spt-psyche$EXE  ->  (archive root)"

echo "binaries : claude-spt-digest$EXE, claude-spt-psyche$EXE, cc-spt-idle-translate$EXE  ->  (archive root)"


#!/bin/sh
# Pack the `adapter.spt` release asset — the end-user distribution acquired by
# `spt adapter add --release SaberMage/spt-claude-code` (doyle's --release source, spt v0.7.3+).
# The archive ROOT must hold `manifest.toml` (named EXACTLY that) + `strings/` + the tool binaries;
# spt-core fetches it from the repo's GitHub release, extracts to the durable home, registers the
# root. DRY-RUN by default. Never uploads — the release/tag/upload is the operator's step.
# [impl->REQ-DIST-ADAPTER-RELEASE]
#
# 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"
APPLY=0
[ "${1:-}" = "--apply" ] && APPLY=1

# PER-OS asset name: the .spt carries native binaries, so it is platform-specific and ships as
# `adapter-<os>-<arch>.spt`. /sptc:setup acquires the matching one via spt's caller-named `--asset`.
# Host-derived from uname; override SPTC_OS/SPTC_ARCH for a cross build / CI. [impl->REQ-DIST-ADAPTER-PEROS]
SPTC_OS="${SPTC_OS:-}"; SPTC_ARCH="${SPTC_ARCH:-}"
if [ -z "$SPTC_OS" ]; then case "$(uname -s 2>/dev/null)" in
  MINGW*|MSYS*|CYGWIN*|Windows*) SPTC_OS=windows ;;
  Linux) SPTC_OS=linux ;;
  Darwin) SPTC_OS=macos ;;
  *) SPTC_OS=unknown ;;
esac; fi
if [ -z "$SPTC_ARCH" ]; then case "$(uname -m 2>/dev/null)" in
  x86_64|amd64) SPTC_ARCH=x86_64 ;;
  arm64|aarch64) SPTC_ARCH=aarch64 ;;
  *) SPTC_ARCH="$(uname -m 2>/dev/null || echo unknown)" ;;
esac; fi
# Native executable suffix, keyed off the DECLARED os (windows → .exe, else bare) — the archive
# carries the binary under its native name so a bare-name template (`claude-spt-digest …`) resolves.
case "$SPTC_OS" in windows) EXE=".exe" ;; *) EXE="" ;; esac
# Binary location: a cross build sets SPTC_TARGET (e.g. x86_64-unknown-linux-gnu → cargo-zigbuild) and
# the binaries land under target/<triple>/release; a native build uses target/release.
if [ -n "${SPTC_TARGET:-}" ]; then RELSUB="$SPTC_TARGET/release"; else RELSUB="release"; fi
DIGEST="$ROOT/tools/claude-spt-digest/target/$RELSUB/claude-spt-digest$EXE"
PSYCHE="$ROOT/tools/claude-spt-psyche/target/$RELSUB/claude-spt-psyche$EXE"
IDLE="$ROOT/tools/cc-spt-idle-translate/target/$RELSUB/cc-spt-idle-translate$EXE"
ASSET="adapter-${SPTC_OS}-${SPTC_ARCH}.spt"
OUT="${ADAPTER_SPT_OUT:-$ROOT/dist/$ASSET}"   # overridable so the unit test writes to a tmp file

# 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

# Require the release binaries (NOT auto-copied by register — they must ride the archive; doyle).
echo
echo "== tool binaries =="
missing=0
for b in "$DIGEST" "$PSYCHE" "$IDLE"; do
  if [ -f "$b" ]; then echo "  ok    $b"; else echo "  MISS  $b"; missing=1; fi
done
if [ "$missing" -ne 0 ]; then
  echo "REFUSING to package: build the release binaries first — sh ci/digest/build.sh && sh ci/psyche/build.sh && sh ci/idle-translate/build.sh" >&2
  exit 1
fi

echo
echo "== plan ($([ "$APPLY" -eq 1 ] && echo APPLY || echo DRY-RUN)) =="
echo "manifest : $MANIFEST  ->  (archive root) manifest.toml"
echo "strings  : $STRINGS/  ->  (archive root) strings/"
echo "binaries : claude-spt-digest$EXE, claude-spt-psyche$EXE  ->  (archive root)"
echo "os/arch  : $SPTC_OS/$SPTC_ARCH"
echo "asset    : $OUT  (-> $ASSET)"
echo "NOTE: the archive is PLATFORM-SPECIFIC (native binaries: '$EXE' build) — hence the per-OS name."
echo "      Ship one asset per OS on the same release; /sptc:setup picks the host's via --asset. Build"
echo "      each OS native, OR cross-build Linux from Windows via cargo-zigbuild + SPTC_TARGET (bare"
echo "      'cargo build --target' fails 'cc not found'; zig provides the linker). On v0.8.0+ Feature B"
echo "      resolves the bundled binaries from the install dir (no PATH copy); pre-0.8.0 they need PATH."

if [ "$APPLY" -ne 1 ]; then
  echo
  echo "DRY-RUN: nothing written. Re-run with --apply to write $OUT, then attach it to a GitHub"
  echo "release on the monorepo. End users acquire it with 'spt adapter add --release"
  echo "SaberMage/spt-claude-code --asset $ASSET' (see docs/RELEASE-RUNBOOK.md). Needs spt v0.7.3+."
  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"
cp "$DIGEST" "$STAGE/claude-spt-digest$EXE"
cp "$PSYCHE" "$STAGE/claude-spt-psyche$EXE"

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

# Self-validate the produced archive: manifest.toml must be at the ROOT (no leading path component).
echo
echo "== validate archive =="
if tar -tzf "$OUT" | grep -qx "manifest.toml"; then
  echo "ok   manifest.toml at archive root"
else
  echo "FATAL: manifest.toml is not at the archive root — adapter add --release would reject it" >&2
  exit 1
fi

echo
echo "WROTE $OUT. Next (operator): attach to a GitHub release on SaberMage/spt-claude-code (alongside"
echo "the other per-OS assets), then end users 'spt adapter add --release SaberMage/spt-claude-code"
echo "--asset $ASSET' (needs spt v0.7.3+). /reload as needed."
exit 0

-echo "binaries : claude-spt-digest$EXE, claude-spt-psyche$EXE  ->  (archive root)"

+echo "binaries : claude-spt-digest$EXE, claude-spt-psyche$EXE, cc-spt-idle-translate$EXE  ->  (archive root)"


cp "$DIGEST" "$STAGE/claude-spt-digest$EXE"
cp "$PSYCHE" "$STAGE/claude-spt-psyche$EXE"

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

cp "$DIGEST" "$STAGE/claude-spt-digest$EXE"
cp "$PSYCHE" "$STAGE/claude-spt-psyche$EXE"
cp "$IDLE" "$STAGE/cc-spt-idle-translate$EXE"

mkdir -p "$(dirname "$OUT")" || { echo "FATAL: cannot create $(dirname "$OUT")"; exit 2; }
( cd "$STAGE" && tar -czf "$OUT" manifest.toml strings claude-spt-digest$EXE claude-spt-psyche$EXE cc-spt-idle-translate$EXE ) \
  || { echo "FATAL: tar failed"; exit 2; }