1	# Name unification: `claude-spt` (spt-core side) + `spt` (Claude Code side)
2
3	## Status
4
5	accepted (2026-06-24)
6
7	## Context
8
9	The project carried **three** names for one thing, plus a launcher shortcut:
10
11	| Identifier | Value | Where it shows |
12	|---|---|---|
13	| GitHub repo | `spt-claude-code` | `spt adapter add --release SaberMage/spt-claude-code`, `[update].repo`, install dir derivation |
14	| spt-core adapter | `claude-spt` | every `api --adapter claude-spt`, `[adapter].name`, `spt adapter update claude-spt` |
15	| cplugs plugin | `sptc` | `/sptc:*` skill prefix, `claude plugin … sptc@cplugs`, marketplace path |
16	| launcher shortcut | `cc` | `cc-<id>` (`spt endpoint run` brand) |
17
18	Three names for one adapter is friction — a user reads `--release SaberMage/spt-claude-code` to install but `spt adapter update claude-spt` to update, and `/sptc:*` to invoke. The hard constraint that prevents collapsing to **one** token: the adapter **cannot** be named `spt` (that is spt-core's own CLI/identity — `spt adapter update spt` reads as "spt updates spt"), yet the user-facing skill prefix **wants** `/spt:*` (the legacy-parity goal, already planned as the `sptc`→`spt` succession). The skill prefix and the adapter id genuinely want different tokens.
19
20	## Decision
21
22	Collapse three names to **two**, by domain:
23
24	- **spt-core-facing identity = `claude-spt`** — the **repo** (rename `spt-claude-code` → `claude-spt`), the **adapter** (unchanged), and the **consolidated binary** (see ADR-0006). After the rename, install and update both read `claude-spt`: `spt adapter add --release SaberMage/claude-spt` ↔ `spt adapter update claude-spt`.
25	- **Claude-Code-facing identity = `spt`** — the **plugin** and its **skills** (`/spt:*`), reached via the already-planned `sptc`→`spt` succession (a single `s/sptc/spt/` substitution), gated on retiring legacy owl's cplugs `spt` plugin (two plugins cannot share the name). Until owl retires, the plugin stays `sptc` / `/sptc:*`.
26	- **launcher shortcut stays `cc`** (`cc-<id>`) — decoupled from the plugin name, unchanged (per ADR-0001).
27
28	End state: `claude-spt` everywhere spt-core sees it; `spt` everywhere the Claude Code user types a skill; `cc` for the spawned-endpoint launcher.
29
30	## Considered and rejected
31
32	- **One token everywhere = `claude-spt`** (incl. `/claude-spt:*` skills). Maximally consistent and dependency-free, but makes the most-typed surface (the skill prefix) long. Rejected — the skill prefix should head to `/spt:*`.
33	- **One token everywhere = `sptc`** (`/sptc:*` unchanged). Shortest and dependency-free, but `sptc` is cryptic to a newcomer and abandons the cleaner `/spt:*` succession the project already committed to. Rejected.
34
35	## Consequences
36
37	- **The repo rename changes the adapter install dir** (`adapters/_github/SaberMage-spt-claude-code` → `…SaberMage-claude-spt`, derived from the repo path), so existing `--release` installs must be re-added. Acceptable: one end user today. GitHub redirects old links; `[update].repo`, the README install chains, CI, and the package scripts must be updated in the same move.
38	- Two names is the floor, not a failure to unify — it reflects a real constraint (adapter ≠ `spt`), recorded here so a future reader does not "simplify" the adapter to `spt` and collide with spt-core.
39	- The `spt` plugin succession keeps its existing gate (owl retirement); this ADR does not change that timing, only commits the target token split.
40

# Name unification: `claude-spt` (spt-core side) + `spt` (Claude Code side)

## Status

accepted (2026-06-24)

## Context

The project carried **three** names for one thing, plus a launcher shortcut:

| Identifier | Value | Where it shows |
|---|---|---|
| GitHub repo | `spt-claude-code` | `spt adapter add --release SaberMage/spt-claude-code`, `[update].repo`, install dir derivation |
| spt-core adapter | `claude-spt` | every `api --adapter claude-spt`, `[adapter].name`, `spt adapter update claude-spt` |
| cplugs plugin | `sptc` | `/sptc:*` skill prefix, `claude plugin … sptc@cplugs`, marketplace path |
| launcher shortcut | `cc` | `cc-<id>` (`spt endpoint run` brand) |

Three names for one adapter is friction — a user reads `--release SaberMage/spt-claude-code` to install but `spt adapter update claude-spt` to update, and `/sptc:*` to invoke. The hard constraint that prevents collapsing to **one** token: the adapter **cannot** be named `spt` (that is spt-core's own CLI/identity — `spt adapter update spt` reads as "spt updates spt"), yet the user-facing skill prefix **wants** `/spt:*` (the legacy-parity goal, already planned as the `sptc`→`spt` succession). The skill prefix and the adapter id genuinely want different tokens.

## Decision

Collapse three names to **two**, by domain:

- **spt-core-facing identity = `claude-spt`** — the **repo** (rename `spt-claude-code` → `claude-spt`), the **adapter** (unchanged), and the **consolidated binary** (see ADR-0006). After the rename, install and update both read `claude-spt`: `spt adapter add --release SaberMage/claude-spt` ↔ `spt adapter update claude-spt`.
- **Claude-Code-facing identity = `spt`** — the **plugin** and its **skills** (`/spt:*`), reached via the already-planned `sptc`→`spt` succession (a single `s/sptc/spt/` substitution), gated on retiring legacy owl's cplugs `spt` plugin (two plugins cannot share the name). Until owl retires, the plugin stays `sptc` / `/sptc:*`.
- **launcher shortcut stays `cc`** (`cc-<id>`) — decoupled from the plugin name, unchanged (per ADR-0001).

End state: `claude-spt` everywhere spt-core sees it; `spt` everywhere the Claude Code user types a skill; `cc` for the spawned-endpoint launcher.

## Considered and rejected

- **One token everywhere = `claude-spt`** (incl. `/claude-spt:*` skills). Maximally consistent and dependency-free, but makes the most-typed surface (the skill prefix) long. Rejected — the skill prefix should head to `/spt:*`.
- **One token everywhere = `sptc`** (`/sptc:*` unchanged). Shortest and dependency-free, but `sptc` is cryptic to a newcomer and abandons the cleaner `/spt:*` succession the project already committed to. Rejected.

## Consequences

- **The repo rename changes the adapter install dir** (`adapters/_github/SaberMage-spt-claude-code` → `…SaberMage-claude-spt`, derived from the repo path), so existing `--release` installs must be re-added. Acceptable: one end user today. GitHub redirects old links; `[update].repo`, the README install chains, CI, and the package scripts must be updated in the same move.
- Two names is the floor, not a failure to unify — it reflects a real constraint (adapter ≠ `spt`), recorded here so a future reader does not "simplify" the adapter to `spt` and collide with spt-core.
- The `spt` plugin succession keeps its existing gate (owl retirement); this ADR does not change that timing, only commits the target token split.


ADR-0005 is name-unification (U3 scope), not RC. Following the REQ-DIST-SESSION-RESUME precedent for unscanned manifest seams: activate both U1/U6 REQs to `["unit"]` (the in-repo scannable evidence I just landed), doc+int deferred.


Edit

required_stages = []  # U1 (quick win, ship first). doc: docs/adr/0006 + RELEASE-RUNBOOK update-message note. impl: [update].message in adapter/claude-spt.toml (the field is docs-confirmed). unit: tests/manifest-*.sh (message present + schema-validates). int: `spt adapter update` prints it on a version apply (dogfood). ACTIVATE at U1 start.

required_stages = ["unit"]  # U1 (quick win, ship first) — ACTIVATED 2026-06-25. The deliverable is the manifest [update].message seam (adapter/claude-spt.toml), and the traceable-reqs scanner does NOT read .toml, so the in-repo scannable evidence is the unit assertion (same convention as REQ-DIST-SESSION-RESUME / REQ-DIST-RC-STARTUP). unit: tests/manifest-shortcut.sh (message present + carries the /reload-plugins notice) + tests/manifest-schema.sh (schema-validates). doc DEFERRED to the docs-site harness-contract/RELEASE-RUNBOOK slice (designed in docs/adr/0006 + UPDATE-NAMING-DOYLE-ASKS.md meanwhile). int DEFERRED: `spt adapter update` prints it on a real version apply (on-node dogfood at the v0.8.0 cut).


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

title = "The adapter manifest [update] table carries a `message` markdown field, printed by spt-core ONLY on a real version apply: it tells the user to run /reload-plugins (the unavoidable TUI step) and points at the more-powerful `spt` CLI endpoint route (`spt endpoint run`) alongside /sptc:live. U1."

-required_stages = []  # U1 (quick win, ship first). doc: docs/adr/0006 + RELEASE-RUNBOOK update-message note. impl: [update].message in adapter/claude-spt.toml (the field is docs-confirmed). unit: tests/manifest-*.sh (message present + schema-validates). int: `spt adapter update` prints it on a version apply (dogfood). ACTIVATE at U1 start.

+required_stages = ["unit"]  # U1 (quick win, ship first) — ACTIVATED 2026-06-25. The deliverable is the manifest [update].message seam (adapter/claude-spt.toml), and the traceable-reqs scanner does NOT read .toml, so the in-repo scannable evidence is the unit assertion (same convention as REQ-DIST-SESSION-RESUME / REQ-DIST-RC-STARTUP). unit: tests/manifest-shortcut.sh (message present + carries the /reload-plugins notice) + tests/manifest-schema.sh (schema-validates). doc DEFERRED to the docs-site harness-contract/RELEASE-RUNBOOK slice (designed in docs/adr/0006 + UPDATE-NAMING-DOYLE-ASKS.md meanwhile). int DEFERRED: `spt adapter update` prints it on a real version apply (on-node dogfood at the v0.8.0 cut).


required_stages = []  # U6 (with U1, isolated one-line manifest change). doc: docs/adr/0005 + UNIFY-CONSOLIDATE-PLAN U6. impl: [session.self].command `claude -n {id} --remote-control {id} --dangerously-skip-permissions` + [session.resume].command adds `-n {id}` (--remote-control {id} already present); {id} already in both keys -> no keys change for the {id}-only form. unit: tests/manifest-shortcut.sh ({id} display-name + RC-channel fill on both paths). int: a fresh `spt endpoint run` endpoint shows {id} as display name + is `spt rc <id>`-attachable; idle delivery + checkpoint still fire (re-run on-node translate/checkpoint dogfood with the flags present). ACTIVATE at U6 start. {node} fill-key ABSENT from the published catalog (operator-owned doyle ask #4); ships {id}-only now.

required_stages = ["unit"]  # U6 (with U1, isolated manifest change) — ACTIVATED 2026-06-25. Manifest seams: [session.self].command = `claude -n {id} --remote-control {id} --dangerously-skip-permissions` + [session.resume].command adds `-n {id}` (--remote-control {id} already present); {id} already in both keys -> no keys change for the {id}-only form. The scanner does NOT read .toml, so the in-repo evidence is the unit assertion (declaration-only seam, same convention as REQ-DIST-SESSION-RESUME). unit: tests/manifest-shortcut.sh ({id} display-name `-n {id}` on BOTH paths + `--remote-control {id}` on [session.self]). doc DEFERRED to the docs-site harness-contract RUN-group slice (designed in UNIFY-CONSOLIDATE-PLAN U6 meanwhile). int DEFERRED: a fresh `spt endpoint run` endpoint shows {id} as display name + is `spt rc <id>`-attachable; idle delivery + checkpoint still fire (on-node translate/checkpoint dogfood at the v0.8.0 cut, flags present). {node} fill-key ABSENT from the published catalog (operator-owned doyle ask #4); ships {id}-only now.


title = "{id} threads into BOTH the session display name (-n {id}, --name) AND the RC channel (--remote-control {id}) on BOTH [session.self] (fresh bringup) and [session.resume], so a fresh and a resumed endpoint are identified + controlled identically. Two real CC flags (verified via claude --help). STRETCH (doyle ask #4, operator-raised): upgrade to {id}@{node} once a {node} fill key lands. U6."

-required_stages = []  # U6 (with U1, isolated one-line manifest change). doc: docs/adr/0005 + UNIFY-CONSOLIDATE-PLAN U6. impl: [session.self].command `claude -n {id} --remote-control {id} --dangerously-skip-permissions` + [session.resume].command adds `-n {id}` (--remote-control {id} already present); {id} already in both keys -> no keys change for the {id}-only form. unit: tests/manifest-shortcut.sh ({id} display-name + RC-channel fill on both paths). int: a fresh `spt endpoint run` endpoint shows {id} as display name + is `spt rc <id>`-attachable; idle delivery + checkpoint still fire (re-run on-node translate/checkpoint dogfood with the flags present). ACTIVATE at U6 start. {node} fill-key ABSENT from the published catalog (operator-owned doyle ask #4); ships {id}-only now.

+required_stages = ["unit"]  # U6 (with U1, isolated manifest change) — ACTIVATED 2026-06-25. Manifest seams: [session.self].command = `claude -n {id} --remote-control {id} --dangerously-skip-permissions` + [session.resume].command adds `-n {id}` (--remote-control {id} already present); {id} already in both keys -> no keys change for the {id}-only form. The scanner does NOT read .toml, so the in-repo evidence is the unit assertion (declaration-only seam, same convention as REQ-DIST-SESSION-RESUME). unit: tests/manifest-shortcut.sh ({id} display-name `-n {id}` on BOTH paths + `--remote-control {id}` on [session.self]). doc DEFERRED to the docs-site harness-contract RUN-group slice (designed in UNIFY-CONSOLIDATE-PLAN U6 meanwhile). int DEFERRED: a fresh `spt endpoint run` endpoint shows {id} as display name + is `spt rc <id>`-attachable; idle delivery + checkpoint still fire (on-node translate/checkpoint dogfood at the v0.8.0 cut, flags present). {node} fill-key ABSENT from the published catalog (operator-owned doyle ask #4); ships {id}-only now.


Now run the gates: manifest-shortcut test, schema validation, traceable-reqs check:


Bash

cd "C:/Users/decid/Documents/projects/spt-claude-code" && echo "=== manifest-shortcut ===" && sh tests/manifest-shortcut.sh; echo "=== manifest-schema ===" && sh tests/manifest-schema.sh 2>&1 | tail -8

Run manifest unit tests


=== manifest-shortcut ===
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   [update].message present
ok   [update].message points at /reload-plugins
ok   [session.self] sets {id} as the display name (-n)
ok   [session.self] threads {id} as the remote-control name
ok   [session.resume] sets {id} as the display name (-n, U6 parity)
ok   [message-idle-translation-binary] path = "cc-spt-idle-translate"
MANIFEST-SHORTCUT OK
=== manifest-schema ===
ok   real manifest validates
ok   catches missing [adapter]
ok   catches missing required adapter.min_spt_core_version
ok   catches invalid adapter.kind enum
ok   catches hook missing required fires
ok   catches non-string shortcut_basename
MANIFEST-SCHEMA OK


All manifest tests green, schema validates the new `message` field. Run traceable-reqs check + full gate suite:


cd "C:/Users/decid/Documents/projects/spt-claude-code" && (./traceable-reqs check 2>&1 || traceable-reqs check 2>&1) | grep -iE "UPDATE-MESSAGE|RC-STARTUP|FAIL|ERROR|uncovered|missing evidence" ; echo "TR_EXIT=${PIPESTATUS[0]}"; echo "=== run-gates ===" && sh ci/run-gates.sh 2>&1 | tail -25

traceable-reqs check + full gate suite


       Deterministic gates (shell-syntax + unit tests + traceable-reqs check + manifest-schema + docs-drift), each a binary pass/fail, runnable as one command
  [OK] REQ-DIST-RC-STARTUP  required: [unit]  stages: -doc -impl +unit -int
  [OK] REQ-DIST-UPDATE-MESSAGE  required: [unit]  stages: -doc -impl +unit -int
TR_EXIT=0
=== run-gates ===
ok  claude-spt-psyche: cargo test + release build

=== GATE: idle-translate (cargo build + test) ===

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

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

=== GATE: traceable-reqs check (requirement coverage) ===
ok  coverage green

=== GATE: skeleton-validate (cplugs installability) ===
ok  skeleton installable

=== GATE: manifest-schema ===
ok   C:/Users/decid/Documents/projects/spt-claude-code/adapter/claude-spt.toml
ok   manifest validates against published manifest.schema.json

=== GATE: docs-drift ===
ok   mdbook build
ok   llms.txt in sync

=== RESULT: PASS ===


All gates PASS, both REQs covered (+unit), traceable green. Commit U1+U6 (evidence tagged in-commit per traceability rule). Check trailer convention first:


cd "C:/Users/decid/Documents/projects/spt-claude-code" && git log -1 --format='%b' 7408c61 | tail -3; echo "---"; git diff --stat

Check commit trailer convention + diff stat


Co-authored by: perri

---
 adapter/claude-spt.toml    | 61 +++++++++++++++++++++++++++++++---------------
 tests/manifest-shortcut.sh | 31 +++++++++++++++++++++++
 traceable-reqs.toml        | 35 ++++++++++++++++++++++++++
 3 files changed, 108 insertions(+), 19 deletions(-)


cd "C:/Users/decid/Documents/projects/spt-claude-code" && git add -A && git commit -q -F - <<'EOF'
feat(unify): U1 [update].message + U6 {id} display/RC parity on both bringup paths

UNIFY-CONSOLIDATE wave (ADR-0005/0006), doyle-independent half. Mints the 6
U-task REQ stubs and lands the two isolated manifest quick-wins.

U1 — REQ-DIST-UPDATE-MESSAGE (activated +unit): [update].message markdown that
spt-core prints on a real apply — bridges the unavoidable /reload-plugins TUI
residual and points at the `spt endpoint run` CLI route alongside /sptc:live.

U6 — REQ-DIST-RC-STARTUP (activated +unit): thread {id} into the display name
(-n {id}) on BOTH [session.self] + [session.resume] and the remote-control
channel (--remote-control {id}) on [session.self] (resume already had it), so a
fresh and a resumed endpoint are identified + RC-controlled identically. {id}
already in both keys -> no keys change. Ships {id}-only; upgrades to {id}@{node}
when the operator-raised {node} fill key (doyle ask #4) lands.

Remaining stubs (REQ-DIST-{BINARY-CONSOLIDATE,NAME-UNIFY,SKELETON-THIN,
INSTALL-UX}) minted inactive — activate-don't-pre-fail, per U2-U5 start.

Unit evidence in tests/manifest-shortcut.sh (manifest .toml is unscanned, so the
seam's in-repo evidence is the unit assertion — REQ-DIST-SESSION-RESUME
convention). doc+int deferred to the docs-site slice / v0.8.0 on-node dogfood.
Gates green: manifest-shortcut, manifest-schema, traceable-reqs check, run-gates.

Co-authored by: perri
EOF
git log -1 --oneline

Commit U1+U6 with evidence


cfe27fd feat(unify): U1 [update].message + U6 {id} display/RC parity on both bringup paths


U1+U6 committed, green. Next per sequencing: **U2** (binary consolidation — the big refactor). Scope the existing crates:


cd "C:/Users/decid/Documents/projects/spt-claude-code" && find tools -maxdepth 2 -name Cargo.toml -o -maxdepth 2 -name '*.rs' | sort; echo "=== workspace? ==="; ls Cargo.toml 2>/dev/null && cat Cargo.toml 2>/dev/null | head -20; echo "=== ci builds ==="; ls ci/digest ci/psyche ci/idle-translate 2>/dev/null

Map tools crates + workspace + ci build scripts


tools/cc-spt-idle-translate/Cargo.toml
tools/claude-spt-digest/Cargo.toml
tools/claude-spt-psyche/Cargo.toml
=== workspace? ===
=== ci builds ===
ci/digest:
build.sh
digest-proof-int.sh
sample.jsonl

ci/idle-translate:
build.sh
translate-proof-int.sh

ci/psyche:
build.sh
live-relay-int.sh


Three independent crates, no workspace. Read both crates to merge into one `claude-spt` with subcommands. Let me inspect structure + entrypoints:


cd "C:/Users/decid/Documents/projects/spt-claude-code" && for c in claude-spt-digest claude-spt-psyche; do echo "===== $c/Cargo.toml ====="; cat tools/$c/Cargo.toml; echo "===== $c src tree ====="; find tools/$c/src -name '*.rs' | sort; done

Read both crates Cargo.toml + src trees


===== claude-spt-digest/Cargo.toml =====
# claude-spt-digest — the [digest] session-digest extractor for the claude-spt adapter.
# Rust for the same reasons legacy spt is Rust: a compiled, dependency-light binary with no
# interpreter-on-PATH or locale-encoding hazards (stdout is always UTF-8), one .exe the fleet can
# spawn bare on Win+Linux. Builds via ci/digest/build.sh, gated in ci/run-gates.sh.
[package]
name = "claude-spt-digest"
version = "0.1.0"
edition = "2021"
publish = false

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

[dependencies]
serde_json = "1"

[profile.release]
strip = true
===== claude-spt-digest src tree =====
tools/claude-spt-digest/src/main.rs
===== claude-spt-psyche/Cargo.toml =====
# claude-spt-psyche — the [session.psyche_init] Psyche runner for the claude-spt adapter (the
# LiveAgent companion). Rust for the SAME reasons as its sibling claude-spt-digest: a compiled,
# dependency-light binary the spt-core daemon can spawn BARE (detached, stdio-null) on Win+Linux
# with no interpreter-on-PATH or locale hazard. `psyche_init.command` in claude-spt.toml launches
# this. std-only (no deps): its whole job is orchestration glue (spawn `claude`, poll its perch).
[package]
name = "claude-spt-psyche"
version = "0.1.0"
edition = "2021"
publish = false

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

[profile.release]
strip = true
===== claude-spt-psyche src tree =====
tools/claude-spt-psyche/src/main.rs


Both single-file, hand-parsed args, dependency-light (digest: serde_json; psyche: std-only). Read both main.rs to plan the merge:


1	//! claude-spt-digest — the `[digest]` session-digest extractor for the claude-spt adapter.
2	//!
3	//! Maps Claude Code's native JSONL transcript -> the published digest-record contract: NDJSON, one
4	//! `{"role","text"?,"tool"?,"ts"?}` object per stdout line. Emits RAW records — spt-core's digest
5	//! renderer applies the `[digest]` presentation defaults (window_turns / arg_truncation /
6	//! sprint_collapse), NOT this extractor. `role` is one of `input | agent | tool` (the published enum).
7	//!
8	//! Per-session by contract: spt-core's session ledger stitches the per-endpoint digest across
9	//! `/clear`; this reads exactly ONE session's log (the watch-item doyle flagged 2026-06-15 —
10	//! single-session slice is correct here, the spanning is ledger-side).
11	//!
12	//! Invoked by spt-core:  `claude-spt-digest --session <session_id> --in <source>`
13	//!   `<source>` is CC's per-project ROOT (`~/.claude/projects`); the session file lives at
14	//!   `<source>/<cwd-slug>/<session_id>.jsonl`. The cwd-slug subdir is a CC-internal encoding not
15	//!   expressible as a flat template, so we LOCATE `<session_id>.jsonl` by search under `<source>`.
16	//!   `spt adapter digest-proof --sample <file>` points `--in` straight at a log file; both shapes
17	//!   are handled (file -> read directly; dir -> locate the session within).
18	//!
19	//! ccs profile (REQ-CCS-PROFILES): a ccs-launched session relocates CC's whole state tree —
20	//! including `projects/` — under `$CLAUDE_CONFIG_DIR` (e.g. `~/.ccs/instances/<account>/.claude`).
21	//! That value is set by ccs at runtime per-account and is NOT expressible as a static manifest path,
22	//! so the `claude-spt:ccs` overlay carries no `[digest]` leaf — instead the dir-locate branch here
23	//! prefers `$CLAUDE_CONFIG_DIR/projects` over the `--in` root. Mirrors the known-good sister project
24	//! claude_skill_owl (owlery::claude_projects_root). The `--sample` file path is never overridden.
25	//!
26	//! Mapping (CC event -> digest record):
27	//!   type=user,  message.content str         -> {role:input, text}              (a real user prompt)
28	//!   type=user,  content[].type=text         -> {role:input, text}
29	//!   type=assistant, content[].type=text     -> {role:agent, text}
30	//!   type=assistant, content[].type=tool_use -> {role:tool,  tool:{name,arg}}
31	//!   thinking / tool_result / attachment / queue-operation / last-prompt -> skipped (not emitted)
32	//! [impl->REQ-DIST-DIGEST-EXTRACTOR]
33
34	use serde_json::{json, Value};
35	use std::fs::{self, File};
36	use std::io::{self, BufRead, BufReader, BufWriter, Write};
37	use std::path::{Path, PathBuf};
38	use std::process::ExitCode;
39
40	/// Single salient arg per tool call (spt-core truncates to arg_truncation). Preference order picks
41	/// the most digest-meaningful field; falls back to the first scalar, then compact JSON of the input.
42	const ARG_KEYS: &[&str] = &[
43	    "file_path", "path", "notebook_path", "command", "pattern", "query", "url", "prompt",
44	    "old_string", "description", "subagent_type",
45	];
46
47	fn scalar_str(v: &Value) -> Option<String> {
48	    match v {
49	        Value::String(s) => Some(s.clone()),
50	        Value::Number(n) => Some(n.to_string()),
51	        Value::Bool(b) => Some(b.to_string()),
52	        _ => None,
53	    }
54	}
55
56	fn tool_arg(input: &Value) -> String {
57	    let obj = match input.as_object() {
58	        Some(o) => o,
59	        None => return String::new(),
60	    };
61	    for k in ARG_KEYS {
62	        if let Some(s) = obj.get(*k).and_then(scalar_str) {
63	            if !s.is_empty() {
64	                return s;
65	            }
66	        }
67	    }
68	    for v in obj.values() {
69	        if let Some(s) = scalar_str(v) {
70	            if !s.is_empty() {
71	                return s;
72	            }
73	        }
74	    }
75	    serde_json::to_string(input).unwrap_or_default()
76	}
77
78	fn rec(role: &str, text: Option<&str>, tool: Option<Value>, ts: Option<&str>) -> Value {
79	    let mut m = serde_json::Map::new();
80	    m.insert("role".into(), json!(role));
81	    if let Some(t) = text {
82	        m.insert("text".into(), json!(t));
83	    }
84	    if let Some(t) = tool {
85	        m.insert("tool".into(), t);
86	    }
87	    if let Some(t) = ts {
88	        if !t.is_empty() {
89	            m.insert("ts".into(), json!(t));
90	        }
91	    }
92	    Value::Object(m)
93	}
94
95	/// Read CC JSONL from `reader`, write digest NDJSON to `writer`. Pure over byte streams so it is
96	/// directly unit-testable without touching the filesystem.
97	fn extract<R: BufRead, W: Write>(reader: R, mut writer: W) -> io::Result<()> {
98	    for line in reader.lines() {
99	        let line = line?;
100	        let line = line.trim();
101	        if line.is_empty() {
102	            continue;
103	        }
104	        let o: Value = match serde_json::from_str(line) {
105	            Ok(v) => v,
106	            Err(_) => continue, // CC's log is well-formed; a stray line is not ours to emit
107	        };
108	        let typ = o.get("type").and_then(Value::as_str).unwrap_or("");
109	        let ts = o.get("timestamp").and_then(Value::as_str);
110	        let msg = match o.get("message").and_then(Value::as_object) {
111	            Some(m) => m,
112	            None => continue,
113	        };
114	        let role = msg.get("role").and_then(Value::as_str).unwrap_or("");
115	        let content = msg.get("content");
116
117	        if typ == "user" && role == "user" {
118	            // A plain prompt is a string. List-content user turns are tool_results -> skip those.
119	            match content {
120	                Some(Value::String(s)) if !s.is_empty() => {
121	                    writeln!(writer, "{}", rec("input", Some(s), None, ts))?;
122	                }
123	                Some(Value::Array(items)) => {
124	                    for it in items {
125	                        if it.get("type").and_then(Value::as_str) == Some("text") {
126	                            if let Some(t) = it.get("text").and_then(Value::as_str) {
127	                                if !t.is_empty() {
128	                                    writeln!(writer, "{}", rec("input", Some(t), None, ts))?;
129	                                }
130	                            }
131	                        }
132	                    }
133	                }
134	                _ => {}
135	            }
136	        } else if typ == "assistant" && role == "assistant" {
137	            if let Some(Value::Array(items)) = content {
138	                for it in items {
139	                    match it.get("type").and_then(Value::as_str) {
140	                        Some("text") => {
141	                            if let Some(t) = it.get("text").and_then(Value::as_str) {
142	                                if !t.is_empty() {
143	                                    writeln!(writer, "{}", rec("agent", Some(t), None, ts))?;
144	                                }
145	                            }
146	                        }
147	                        Some("tool_use") => {
148	                            let name = it.get("name").and_then(Value::as_str).unwrap_or("");
149	                            let arg = it.get("input").map(tool_arg).unwrap_or_default();
150	                            let tool = json!({"name": name, "arg": arg});
151	                            writeln!(writer, "{}", rec("tool", None, Some(tool), ts))?;
152	                        }
153	                        _ => {} // thinking, etc. -> skip
154	                    }
155	                }
156	            }
157	        }
158	        // other event types -> skip
159	    }
160	    writer.flush()
161	}
162
163	/// Locate `<session>.jsonl` under `root` (depth-1 guess first, then a bounded recursive sweep —
164	/// the cwd-slug subdir is CC-internal so the exact path is not known a priori).
165	fn locate(root: &Path, session: &str) -> Option<PathBuf> {
166	    let target = format!("{session}.jsonl");
167	    // Depth-1: root/<slug>/<session>.jsonl
168	    if let Ok(entries) = fs::read_dir(root) {
169	        for e in entries.flatten() {
170	            let p = e.path();
171	            if p.is_dir() {
172	                let cand = p.join(&target);
173	                if cand.is_file() {
174	                    return Some(cand);
175	                }
176	            }
177	        }
178	    }
179	    // Bounded recursive fallback.
180	    find_recursive(root, &target, 6)
181	}
182
183	fn find_recursive(dir: &Path, target: &str, depth: usize) -> Option<PathBuf> {
184	    if depth == 0 {
185	        return None;
186	    }
187	    let entries = fs::read_dir(dir).ok()?;
188	    for e in entries.flatten() {
189	        let p = e.path();
190	        if p.is_file() {
191	            if p.file_name().and_then(|n| n.to_str()) == Some(target) {
192	                return Some(p);
193	            }
194	        } else if p.is_dir() {
195	            if let Some(hit) = find_recursive(&p, target, depth - 1) {
196	                return Some(hit);
197	            }
198	        }
199	    }
200	    None
201	}
202
203	struct Args {
204	    session: Option<String>,
205	    source: Option<String>,
206	}
207
208	fn parse_args() -> Result<Args, String> {
209	    let mut session = None;
210	    let mut source = None;
211	    let mut it = std::env::args().skip(1);
212	    while let Some(a) = it.next() {
213	        match a.as_str() {
214	            "--session" => session = it.next(),
215	            "--in" => source = it.next(),
216	            "-h" | "--help" => {
217	                println!("claude-spt-digest --session <id> --in <projects-root-or-logfile>");
218	                std::process::exit(0);
219	            }
220	            other => return Err(format!("unknown arg: {other}")),
221	        }
222	    }
223	    Ok(Args { session, source })
224	}
225
226	/// Expand a leading `~/` (or bare `~`) to the user's home. spt-core resolves `~` in the `source`
227	/// template, but the extractor expands defensively too — it runs argv-direct (no shell), so a
228	/// literal `~` would otherwise become a bogus relative path.
229	fn expand_tilde(p: &str) -> PathBuf {
230	    if p == "~" || p.starts_with("~/") || p.starts_with("~\\") {
231	        if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
232	            let rest = p[1..].trim_start_matches(['/', '\\']);
233	            return Path::new(&home).join(rest);
234	        }
235	    }
236	    PathBuf::from(p)
237	}
238
239	/// ccs (and any CLAUDE_CONFIG_DIR-relocating launcher) move CC's state tree — including the
240	/// `projects/` transcript root — under `$CLAUDE_CONFIG_DIR`. Returns `$CLAUDE_CONFIG_DIR/projects`
241	/// when `cfg` is `Some` and non-empty, else `None` (caller falls back to the `--in` root). Pure over
242	/// its input so the env read stays at the edge and this is unit-testable without env races. Mirrors
243	/// the known-good claude_skill_owl resolver (owlery::claude_projects_root). [impl->REQ-CCS-PROFILES]
244	fn ccs_projects_root_from(cfg: Option<&str>) -> Option<PathBuf> {
245	    match cfg {
246	        Some(c) if !c.is_empty() => Some(PathBuf::from(c).join("projects")),
247	        _ => None,
248	    }
249	}
250
251	fn run() -> Result<(), String> {
252	    let args = parse_args()?;
253	    let source = args.source.ok_or("missing required --in")?;
254	    let src = expand_tilde(&source);
255
256	    // --in may be a direct log FILE (digest-proof --sample) -> read it as-is, no env override.
257	    let path: Option<PathBuf> = if src.is_file() {
258	        Some(src)
259	    } else {
260	        // Directory ("projects root") branch. Honor a ccs-relocated config tree: prefer
261	        // $CLAUDE_CONFIG_DIR/projects over the manifest `--in` root (REQ-CCS-PROFILES).
262	        let env_cfg = std::env::var("CLAUDE_CONFIG_DIR").ok();
263	        let root = ccs_projects_root_from(env_cfg.as_deref()).unwrap_or(src);
264	        if root.is_dir() {
265	            let session = args.session.ok_or("--in is a directory but no --session to locate")?;
266	            locate(&root, &session)
267	        } else {
268	            None
269	        }
270	    };
271
272	    let path = match path {
273	        Some(p) => p,
274	        None => return Ok(()), // no log -> empty digest (not an error; spt-core reports the empty)
275	    };
276
277	    let file = File::open(&path).map_err(|e| format!("open {}: {e}", path.display()))?;
278	    let stdout = io::stdout();
279	    let writer = BufWriter::new(stdout.lock());
280	    extract(BufReader::new(file), writer).map_err(|e| e.to_string())
281	}
282
283	fn main() -> ExitCode {
284	    match run() {
285	        Ok(()) => ExitCode::SUCCESS,
286	        Err(e) => {
287	            eprintln!("claude-spt-digest: {e}");
288	            ExitCode::from(2)
289	        }
290	    }
291	}
292
293	// [unit->REQ-DIST-DIGEST-EXTRACTOR]
294	#[cfg(test)]
295	mod tests {
296	    use super::*;
297	    use serde_json::Value;
298
299	    fn run_extract(input: &str) -> Vec<Value> {
300	        let mut out = Vec::new();
301	        extract(input.as_bytes(), &mut out).unwrap();
302	        String::from_utf8(out)
303	            .unwrap()
304	            .lines()
305	            .filter(|l| !l.trim().is_empty())
306	            .map(|l| serde_json::from_str(l).expect("each output line is valid JSON"))
307	            .collect()
308	    }
309
310	    #[test]
311	    fn user_string_is_input() {
312	        let recs = run_extract(
313	            r#"{"type":"user","timestamp":"2026-06-15T10:00:00Z","message":{"role":"user","content":"hello"}}"#,
314	        );
315	        assert_eq!(recs.len(), 1);
316	        assert_eq!(recs[0]["role"], "input");
317	        assert_eq!(recs[0]["text"], "hello");
318	        assert_eq!(recs[0]["ts"], "2026-06-15T10:00:00Z");
319	    }
320
321	    #[test]
322	    fn assistant_text_is_agent_thinking_skipped() {
323	        let recs = run_extract(
324	            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","thinking":"x"},{"type":"text","text":"hi"}]}}"#,
325	        );
326	        assert_eq!(recs.len(), 1, "thinking dropped, text kept");
327	        assert_eq!(recs[0]["role"], "agent");
328	        assert_eq!(recs[0]["text"], "hi");
329	    }
330
331	    #[test]
332	    fn tool_use_maps_name_and_arg() {
333	        let recs = run_extract(
334	            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Write","input":{"file_path":"src/a.rs","content":"fn main(){}"}}]}}"#,
335	        );
336	        assert_eq!(recs.len(), 1);
337	        assert_eq!(recs[0]["role"], "tool");
338	        assert_eq!(recs[0]["tool"]["name"], "Write");
339	        assert_eq!(recs[0]["tool"]["arg"], "src/a.rs"); // file_path preferred over content
340	    }
341
342	    #[test]
343	    fn tool_arg_prefers_command_then_first_scalar() {
344	        let recs = run_extract(
345	            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{"command":"cargo build","description":"d"}}]}}"#,
346	        );
347	        assert_eq!(recs[0]["tool"]["arg"], "cargo build");
348	    }
349
350	    #[test]
351	    fn user_tool_result_is_skipped() {
352	        let recs = run_extract(
353	            r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"ok"}]}}"#,
354	        );
355	        assert!(recs.is_empty(), "tool_result has no contract role");
356	    }
357
358	    #[test]
359	    fn noise_types_and_bad_lines_skipped() {
360	        let input = concat!(
361	            r#"{"type":"attachment","attachment":{}}"#,
362	            "\n",
363	            r#"{"type":"last-prompt","lastPrompt":"x"}"#,
364	            "\n",
365	            "this is not json\n",
366	            "\n",
367	            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"only this"}]}}"#,
368	        );
369	        let recs = run_extract(input);
370	        assert_eq!(recs.len(), 1);
371	        assert_eq!(recs[0]["text"], "only this");
372	    }
373
374	    #[test]
375	    fn non_ascii_round_trips_utf8() {
376	        let recs = run_extract(
377	            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"done — built clean"}]}}"#,
378	        );
379	        assert_eq!(recs[0]["text"], "done — built clean");
380	    }
381
382	    #[test]
383	    fn tilde_expands_to_home() {
384	        std::env::set_var("HOME", "/home/test");
385	        assert_eq!(expand_tilde("~/.claude/projects"), Path::new("/home/test").join(".claude/projects"));
386	        assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path"));
387	        assert_eq!(expand_tilde("rel/path"), PathBuf::from("rel/path"));
388	    }
389
390	    // [unit->REQ-CCS-PROFILES] ccs CLAUDE_CONFIG_DIR-aware projects-root resolution. Pure helper so
391	    // no env mutation / no test races (owl parity: set -> $CFG/projects; unset/empty -> fall back).
392	    #[test]
393	    fn ccs_root_honors_config_dir() {
394	        assert_eq!(
395	            ccs_projects_root_from(Some("/tmp/ccs-acct/.claude")),
396	            Some(Path::new("/tmp/ccs-acct/.claude").join("projects"))
397	        );
398	    }
399
400	    #[test]
401	    fn ccs_root_none_when_unset() {
402	        assert_eq!(ccs_projects_root_from(None), None);
403	    }
404
405	    #[test]
406	    fn ccs_root_ignores_empty() {
407	        // Empty env var must NOT shadow the --in fallback (owl: `!cfg.is_empty()` guard).
408	        assert_eq!(ccs_projects_root_from(Some("")), None);
409	    }
410
411	    #[test]
412	    fn every_record_satisfies_the_contract() {
413	        // role enum + field-presence invariants across a mixed stream.
414	        let input = concat!(
415	            r#"{"type":"user","message":{"role":"user","content":"q"}}"#,
416	            "\n",
417	            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"a"},{"type":"tool_use","name":"Read","input":{"file_path":"f"}}]}}"#,
418	        );
419	        for r in run_extract(input) {
420	            let role = r["role"].as_str().unwrap();
421	            assert!(matches!(role, "input" | "agent" | "tool"), "role enum: {role}");
422	            match role {
423	                "input" | "agent" => assert!(r.get("text").is_some() && r.get("tool").is_none()),
424	                "tool" => {
425	                    assert!(r.get("text").is_none());
426	                    assert!(r["tool"].get("name").is_some() && r["tool"].get("arg").is_some());
427	                }
428	                _ => unreachable!(),
429	            }
430	        }
431	    }
432	}
433

//! claude-spt-digest — the `[digest]` session-digest extractor for the claude-spt adapter.
//!
//! Maps Claude Code's native JSONL transcript -> the published digest-record contract: NDJSON, one
//! `{"role","text"?,"tool"?,"ts"?}` object per stdout line. Emits RAW records — spt-core's digest
//! renderer applies the `[digest]` presentation defaults (window_turns / arg_truncation /
//! sprint_collapse), NOT this extractor. `role` is one of `input | agent | tool` (the published enum).
//!
//! Per-session by contract: spt-core's session ledger stitches the per-endpoint digest across
//! `/clear`; this reads exactly ONE session's log (the watch-item doyle flagged 2026-06-15 —
//! single-session slice is correct here, the spanning is ledger-side).
//!
//! Invoked by spt-core:  `claude-spt-digest --session <session_id> --in <source>`
//!   `<source>` is CC's per-project ROOT (`~/.claude/projects`); the session file lives at
//!   `<source>/<cwd-slug>/<session_id>.jsonl`. The cwd-slug subdir is a CC-internal encoding not
//!   expressible as a flat template, so we LOCATE `<session_id>.jsonl` by search under `<source>`.
//!   `spt adapter digest-proof --sample <file>` points `--in` straight at a log file; both shapes
//!   are handled (file -> read directly; dir -> locate the session within).
//!
//! ccs profile (REQ-CCS-PROFILES): a ccs-launched session relocates CC's whole state tree —
//! including `projects/` — under `$CLAUDE_CONFIG_DIR` (e.g. `~/.ccs/instances/<account>/.claude`).
//! That value is set by ccs at runtime per-account and is NOT expressible as a static manifest path,
//! so the `claude-spt:ccs` overlay carries no `[digest]` leaf — instead the dir-locate branch here
//! prefers `$CLAUDE_CONFIG_DIR/projects` over the `--in` root. Mirrors the known-good sister project
//! claude_skill_owl (owlery::claude_projects_root). The `--sample` file path is never overridden.
//!
//! Mapping (CC event -> digest record):
//!   type=user,  message.content str         -> {role:input, text}              (a real user prompt)
//!   type=user,  content[].type=text         -> {role:input, text}
//!   type=assistant, content[].type=text     -> {role:agent, text}
//!   type=assistant, content[].type=tool_use -> {role:tool,  tool:{name,arg}}
//!   thinking / tool_result / attachment / queue-operation / last-prompt -> skipped (not emitted)
//! [impl->REQ-DIST-DIGEST-EXTRACTOR]

use serde_json::{json, Value};
use std::fs::{self, File};
use std::io::{self, BufRead, BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;

/// Single salient arg per tool call (spt-core truncates to arg_truncation). Preference order picks
/// the most digest-meaningful field; falls back to the first scalar, then compact JSON of the input.
const ARG_KEYS: &[&str] = &[
    "file_path", "path", "notebook_path", "command", "pattern", "query", "url", "prompt",
    "old_string", "description", "subagent_type",
];

fn scalar_str(v: &Value) -> Option<String> {
    match v {
        Value::String(s) => Some(s.clone()),
        Value::Number(n) => Some(n.to_string()),
        Value::Bool(b) => Some(b.to_string()),
        _ => None,
    }
}

fn tool_arg(input: &Value) -> String {
    let obj = match input.as_object() {
        Some(o) => o,
        None => return String::new(),
    };
    for k in ARG_KEYS {
        if let Some(s) = obj.get(*k).and_then(scalar_str) {
            if !s.is_empty() {
                return s;
            }
        }
    }
    for v in obj.values() {
        if let Some(s) = scalar_str(v) {
            if !s.is_empty() {
                return s;
            }
        }
    }
    serde_json::to_string(input).unwrap_or_default()
}

fn rec(role: &str, text: Option<&str>, tool: Option<Value>, ts: Option<&str>) -> Value {
    let mut m = serde_json::Map::new();
    m.insert("role".into(), json!(role));
    if let Some(t) = text {
        m.insert("text".into(), json!(t));
    }
    if let Some(t) = tool {
        m.insert("tool".into(), t);
    }
    if let Some(t) = ts {
        if !t.is_empty() {
            m.insert("ts".into(), json!(t));
        }
    }
    Value::Object(m)
}

/// Read CC JSONL from `reader`, write digest NDJSON to `writer`. Pure over byte streams so it is
/// directly unit-testable without touching the filesystem.
fn extract<R: BufRead, W: Write>(reader: R, mut writer: W) -> io::Result<()> {
    for line in reader.lines() {
        let line = line?;
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        let o: Value = match serde_json::from_str(line) {
            Ok(v) => v,
            Err(_) => continue, // CC's log is well-formed; a stray line is not ours to emit
        };
        let typ = o.get("type").and_then(Value::as_str).unwrap_or("");
        let ts = o.get("timestamp").and_then(Value::as_str);
        let msg = match o.get("message").and_then(Value::as_object) {
            Some(m) => m,
            None => continue,
        };
        let role = msg.get("role").and_then(Value::as_str).unwrap_or("");
        let content = msg.get("content");

        if typ == "user" && role == "user" {
            // A plain prompt is a string. List-content user turns are tool_results -> skip those.
            match content {
                Some(Value::String(s)) if !s.is_empty() => {
                    writeln!(writer, "{}", rec("input", Some(s), None, ts))?;
                }
                Some(Value::Array(items)) => {
                    for it in items {
                        if it.get("type").and_then(Value::as_str) == Some("text") {
                            if let Some(t) = it.get("text").and_then(Value::as_str) {
                                if !t.is_empty() {
                                    writeln!(writer, "{}", rec("input", Some(t), None, ts))?;
                                }
                            }
                        }
                    }
                }
                _ => {}
            }
        } else if typ == "assistant" && role == "assistant" {
            if let Some(Value::Array(items)) = content {
                for it in items {
                    match it.get("type").and_then(Value::as_str) {
                        Some("text") => {
                            if let Some(t) = it.get("text").and_then(Value::as_str) {
                                if !t.is_empty() {
                                    writeln!(writer, "{}", rec("agent", Some(t), None, ts))?;
                                }
                            }
                        }
                        Some("tool_use") => {
                            let name = it.get("name").and_then(Value::as_str).unwrap_or("");
                            let arg = it.get("input").map(tool_arg).unwrap_or_default();
                            let tool = json!({"name": name, "arg": arg});
                            writeln!(writer, "{}", rec("tool", None, Some(tool), ts))?;
                        }
                        _ => {} // thinking, etc. -> skip
                    }
                }
            }
        }
        // other event types -> skip
    }
    writer.flush()
}

/// Locate `<session>.jsonl` under `root` (depth-1 guess first, then a bounded recursive sweep —
/// the cwd-slug subdir is CC-internal so the exact path is not known a priori).
fn locate(root: &Path, session: &str) -> Option<PathBuf> {
    let target = format!("{session}.jsonl");
    // Depth-1: root/<slug>/<session>.jsonl
    if let Ok(entries) = fs::read_dir(root) {
        for e in entries.flatten() {
            let p = e.path();
            if p.is_dir() {
                let cand = p.join(&target);
                if cand.is_file() {
                    return Some(cand);
                }
            }
        }
    }
    // Bounded recursive fallback.
    find_recursive(root, &target, 6)
}

fn find_recursive(dir: &Path, target: &str, depth: usize) -> Option<PathBuf> {
    if depth == 0 {
        return None;
    }
    let entries = fs::read_dir(dir).ok()?;
    for e in entries.flatten() {
        let p = e.path();
        if p.is_file() {
            if p.file_name().and_then(|n| n.to_str()) == Some(target) {
                return Some(p);
            }
        } else if p.is_dir() {
            if let Some(hit) = find_recursive(&p, target, depth - 1) {
                return Some(hit);
            }
        }
    }
    None
}

struct Args {
    session: Option<String>,
    source: Option<String>,
}

fn parse_args() -> Result<Args, String> {
    let mut session = None;
    let mut source = None;
    let mut it = std::env::args().skip(1);
    while let Some(a) = it.next() {
        match a.as_str() {
            "--session" => session = it.next(),
            "--in" => source = it.next(),
            "-h" | "--help" => {
                println!("claude-spt-digest --session <id> --in <projects-root-or-logfile>");
                std::process::exit(0);
            }
            other => return Err(format!("unknown arg: {other}")),
        }
    }
    Ok(Args { session, source })
}

/// Expand a leading `~/` (or bare `~`) to the user's home. spt-core resolves `~` in the `source`
/// template, but the extractor expands defensively too — it runs argv-direct (no shell), so a
/// literal `~` would otherwise become a bogus relative path.
fn expand_tilde(p: &str) -> PathBuf {
    if p == "~" || p.starts_with("~/") || p.starts_with("~\\") {
        if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
            let rest = p[1..].trim_start_matches(['/', '\\']);
            return Path::new(&home).join(rest);
        }
    }
    PathBuf::from(p)
}

/// ccs (and any CLAUDE_CONFIG_DIR-relocating launcher) move CC's state tree — including the
/// `projects/` transcript root — under `$CLAUDE_CONFIG_DIR`. Returns `$CLAUDE_CONFIG_DIR/projects`
/// when `cfg` is `Some` and non-empty, else `None` (caller falls back to the `--in` root). Pure over
/// its input so the env read stays at the edge and this is unit-testable without env races. Mirrors
/// the known-good claude_skill_owl resolver (owlery::claude_projects_root). [impl->REQ-CCS-PROFILES]
fn ccs_projects_root_from(cfg: Option<&str>) -> Option<PathBuf> {
    match cfg {
        Some(c) if !c.is_empty() => Some(PathBuf::from(c).join("projects")),
        _ => None,
    }
}

fn run() -> Result<(), String> {
    let args = parse_args()?;
    let source = args.source.ok_or("missing required --in")?;
    let src = expand_tilde(&source);

    // --in may be a direct log FILE (digest-proof --sample) -> read it as-is, no env override.
    let path: Option<PathBuf> = if src.is_file() {
        Some(src)
    } else {
        // Directory ("projects root") branch. Honor a ccs-relocated config tree: prefer
        // $CLAUDE_CONFIG_DIR/projects over the manifest `--in` root (REQ-CCS-PROFILES).
        let env_cfg = std::env::var("CLAUDE_CONFIG_DIR").ok();
        let root = ccs_projects_root_from(env_cfg.as_deref()).unwrap_or(src);
        if root.is_dir() {
            let session = args.session.ok_or("--in is a directory but no --session to locate")?;
            locate(&root, &session)
        } else {
            None
        }
    };

    let path = match path {
        Some(p) => p,
        None => return Ok(()), // no log -> empty digest (not an error; spt-core reports the empty)
    };

    let file = File::open(&path).map_err(|e| format!("open {}: {e}", path.display()))?;
    let stdout = io::stdout();
    let writer = BufWriter::new(stdout.lock());
    extract(BufReader::new(file), writer).map_err(|e| e.to_string())
}

fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(e) => {
            eprintln!("claude-spt-digest: {e}");
            ExitCode::from(2)
        }
    }
}

// [unit->REQ-DIST-DIGEST-EXTRACTOR]
#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::Value;

    fn run_extract(input: &str) -> Vec<Value> {
        let mut out = Vec::new();
        extract(input.as_bytes(), &mut out).unwrap();
        String::from_utf8(out)
            .unwrap()
            .lines()
            .filter(|l| !l.trim().is_empty())
            .map(|l| serde_json::from_str(l).expect("each output line is valid JSON"))
            .collect()
    }

    #[test]
    fn user_string_is_input() {
        let recs = run_extract(
            r#"{"type":"user","timestamp":"2026-06-15T10:00:00Z","message":{"role":"user","content":"hello"}}"#,
        );
        assert_eq!(recs.len(), 1);
        assert_eq!(recs[0]["role"], "input");
        assert_eq!(recs[0]["text"], "hello");
        assert_eq!(recs[0]["ts"], "2026-06-15T10:00:00Z");
    }

    #[test]
    fn assistant_text_is_agent_thinking_skipped() {
        let recs = run_extract(
            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","thinking":"x"},{"type":"text","text":"hi"}]}}"#,
        );
        assert_eq!(recs.len(), 1, "thinking dropped, text kept");
        assert_eq!(recs[0]["role"], "agent");
        assert_eq!(recs[0]["text"], "hi");
    }

    #[test]
    fn tool_use_maps_name_and_arg() {
        let recs = run_extract(
            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Write","input":{"file_path":"src/a.rs","content":"fn main(){}"}}]}}"#,
        );
        assert_eq!(recs.len(), 1);
        assert_eq!(recs[0]["role"], "tool");
        assert_eq!(recs[0]["tool"]["name"], "Write");
        assert_eq!(recs[0]["tool"]["arg"], "src/a.rs"); // file_path preferred over content
    }

    #[test]
    fn tool_arg_prefers_command_then_first_scalar() {
        let recs = run_extract(
            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{"command":"cargo build","description":"d"}}]}}"#,
        );
        assert_eq!(recs[0]["tool"]["arg"], "cargo build");
    }

    #[test]
    fn user_tool_result_is_skipped() {
        let recs = run_extract(
            r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"ok"}]}}"#,
        );
        assert!(recs.is_empty(), "tool_result has no contract role");
    }

    #[test]
    fn noise_types_and_bad_lines_skipped() {
        let input = concat!(
            r#"{"type":"attachment","attachment":{}}"#,
            "\n",
            r#"{"type":"last-prompt","lastPrompt":"x"}"#,
            "\n",
            "this is not json\n",
            "\n",
            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"only this"}]}}"#,
        );
        let recs = run_extract(input);
        assert_eq!(recs.len(), 1);
        assert_eq!(recs[0]["text"], "only this");
    }

    #[test]
    fn non_ascii_round_trips_utf8() {
        let recs = run_extract(
            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"done — built clean"}]}}"#,
        );
        assert_eq!(recs[0]["text"], "done — built clean");
    }

    #[test]
    fn tilde_expands_to_home() {
        std::env::set_var("HOME", "/home/test");
        assert_eq!(expand_tilde("~/.claude/projects"), Path::new("/home/test").join(".claude/projects"));
        assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path"));
        assert_eq!(expand_tilde("rel/path"), PathBuf::from("rel/path"));
    }

    // [unit->REQ-CCS-PROFILES] ccs CLAUDE_CONFIG_DIR-aware projects-root resolution. Pure helper so
    // no env mutation / no test races (owl parity: set -> $CFG/projects; unset/empty -> fall back).
    #[test]
    fn ccs_root_honors_config_dir() {
        assert_eq!(
            ccs_projects_root_from(Some("/tmp/ccs-acct/.claude")),
            Some(Path::new("/tmp/ccs-acct/.claude").join("projects"))
        );
    }

    #[test]
    fn ccs_root_none_when_unset() {
        assert_eq!(ccs_projects_root_from(None), None);
    }

    #[test]
    fn ccs_root_ignores_empty() {
        // Empty env var must NOT shadow the --in fallback (owl: `!cfg.is_empty()` guard).
        assert_eq!(ccs_projects_root_from(Some("")), None);
    }

    #[test]
    fn every_record_satisfies_the_contract() {
        // role enum + field-presence invariants across a mixed stream.
        let input = concat!(
            r#"{"type":"user","message":{"role":"user","content":"q"}}"#,
            "\n",
            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"a"},{"type":"tool_use","name":"Read","input":{"file_path":"f"}}]}}"#,
        );
        for r in run_extract(input) {
            let role = r["role"].as_str().unwrap();
            assert!(matches!(role, "input" | "agent" | "tool"), "role enum: {role}");
            match role {
                "input" | "agent" => assert!(r.get("text").is_some() && r.get("tool").is_none()),
                "tool" => {
                    assert!(r.get("text").is_none());
                    assert!(r["tool"].get("name").is_some() && r["tool"].get("arg").is_some());
                }
                _ => unreachable!(),
            }
        }
    }
}


1	//! claude-spt-psyche — the `[session.psyche_init]` Psyche runner for the claude-spt LiveAgent.
2	//!
3	//! WHAT A PSYCHE IS (doyle 2026-06-15, traced spt-core): a LiveAgent's detached companion. The
4	//! spt-core daemon's livehost hosts it — only when the resolved manifest declares
5	//! `[session.psyche_init]` AND the perch came up as state=live_agent (the live `api listen` path;
6	//! Option A 2026-06-17 — psyche_init lives in the BASE claude-spt manifest, no `:live` profile; a
7	//! ready/poll perch is skipped regardless, livehost.rs:282). The Psyche owns its OWN perch
8	//! (`<parent>-psyche`), receives daemon PULSES
9	//! on that perch, and on each pulse authors a COMMUNE delta (a context brief for the parent's
10	//! resume across `/clear`/compact) — it NEVER replies/notifies (the echo-commune is a distinct,
11	//! cheaper actor). It exits at session end. The Psyche is daemon-MANAGED by contract (decoupled
12	//! from message delivery); this runner does NOT orchestrate its own lifecycle — it is the dumb
13	//! resident wrapper the daemon launches and the daemon tears down (perch close = our exit signal).
14	//!
15	//! WHY A RESIDENT WRAPPER (not bare `claude -p`): `claude -p` is ONE-SHOT (one turn, then exits;
16	//! Stop hooks cannot re-loop it), and CC is migrating off `-p` billing-wise. So the Psyche-as-an-
17	//! LLM is a headless `claude` session in `{psyche_dir}` that we KEEP ALIVE: seed it once with the
18	//! daemon-supplied `{psyche_prompt}` (which tells that claude its Psyche role + the commune-on-pulse
19	//! contract), then drive one `claude --continue` turn per pulse. The COMMUNE authoring lives in
20	//! that claude (driven by the prompt) — this runner stays pure orchestration glue.
21	//!
22	//! Invoked by the daemon (detached, cwd=`{psyche_dir}`, stdio null):
23	//!   claude-spt-psyche --id <parent>-psyche --session-id <session_id> --prompt <psyche_prompt…>
24	//! ({id} is the daemon-OVERRIDDEN `<parent>-psyche`, NOT the parent endpoint id — see spawn_psyche.)
25	//! NOTE on `--prompt` parsing: spt-core **<0.8.2** substituted `{psyche_prompt}` into the command
26	//! STRING then whitespace-split it, so the multi-word prompt reached us as many trailing argv tokens.
27	//! spt-core **0.8.2+** fills each `{key}` as exactly ONE argv element (tokenize-then-fill — re-validated
28	//! 2026-06-17: the prompt arrives as a single element, newlines intact). We parse `--prompt` GREEDILY
29	//! (slurp the rest) anyway — defensive: it reconstructs a split prompt on <0.8.2 AND passes the single
30	//! 0.8.2 element through unchanged. Keep `--prompt` LAST in the command template. [impl->REQ-SKILL-LIVE]
31	//!
32	//! Loop:  seed `claude -p <prompt>`  ->  forever { `spt ready <id> --once` (blocks for one pulse;
33	//! its stdout is the pulse body) ; perch-closed => exit ; empty drain => next ; else feed the pulse
34	//! to `claude --continue -p <pulse>` }. The perch register + backlog drain are the `spt ready`
35	//! command's own job (ready.md), so no separate establish step.
36	//! [impl->REQ-SKILL-LIVE]
37
38	#[cfg(not(test))]
39	use std::process::{Command, ExitCode, Stdio};
40
41	/// Parsed launch args. Only the three daemon-filled `psyche_init.keys` we template
42	/// (`{id, session_id, psyche_prompt}`; `{psyche_dir}` is consumed as cwd by the daemon, not argv).
43	#[derive(Debug, PartialEq)]
44	struct Args {
45	    id: String,
46	    session_id: String,
47	    prompt: String,
48	}
49
50	impl Args {
51	    /// Parse `--id V --session-id V --prompt <rest…>`. `--id`/`--session-id` are single-value and
52	    /// order-independent; `--prompt` is TERMINAL and GREEDY — it slurps every remaining token as the
53	    /// prompt (rejoined with single spaces). History: spt-core **<0.8.2** substituted `{psyche_prompt}`
54	    /// into the `[session.psyche_init]` command STRING then whitespace-split it, so a multi-word prompt
55	    /// (it always is — "PSYCHE REVIVAL time: … incoming event: …") arrived as many tokens, NOT one; a
56	    /// non-greedy `--prompt` read only the first word and rejected the second as "unknown arg" → instant
57	    /// exit 2 → the daemon recorded a phantom hosted Psyche (diagnosed v0.8.1, 2026-06-16). spt-core
58	    /// **0.8.2** fixed it (fills each `{key}` as ONE argv element; re-validated 2026-06-17). Greedy is
59	    /// KEPT as defensive: with one element, `collect().join(" ")` returns it unchanged; with the old
60	    /// split, it reconstructs. Our manifest places `--prompt` last. Returns the offending flag on a
61	    /// missing value or an unknown arg seen BEFORE `--prompt`, so a real misfire is still loud.
62	    fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args, String> {
63	        let (mut id, mut session_id, mut prompt) = (None, None, None);
64	        let mut it = argv.into_iter();
65	        while let Some(flag) = it.next() {
66	            let want = |it: &mut dyn Iterator<Item = String>| {
67	                it.next().ok_or_else(|| format!("{flag} expects a value"))
68	            };
69	            match flag.as_str() {
70	                "--id" => id = Some(want(&mut it)?),
71	                "--session-id" => session_id = Some(want(&mut it)?),
72	                // Terminal + greedy: consume ALL remaining tokens (spt-core whitespace-split the prompt).
73	                "--prompt" => {
74	                    prompt = Some(it.by_ref().collect::<Vec<_>>().join(" "));
75	                }
76	                other => return Err(format!("unknown arg: {other}")),
77	            }
78	        }
79	        Ok(Args {
80	            id: id.ok_or("missing --id")?,
81	            session_id: session_id.ok_or("missing --session-id")?,
82	            prompt: prompt.ok_or("missing --prompt")?,
83	        })
84	    }
85	}
86
87	/// Tool/permission SANDBOX flags applied to EVERY psyche claude turn (seed + each pulse) — legacy
88	/// owl parity (claude_skill_owl `src/live/wrapper/claude.rs`, init/resume/final all carry this exact
89	/// set). The Psyche is a CONSTRAINED companion, not a general agent:
90	///   * `--tools Read,Edit,Write` — the commune authoring needs file IO only; NO Bash/network/etc.
91	///     (legacy ALSO scopes an `--agents owl-psyche` subagent to the same three; the session-level
92	///     `--tools` cap is the effective gate, and our role-prompt arrives via `-p`, not an embedded
93	///     `--agents` template, so we apply the cap directly rather than recreating the subagent).
94	///   * `--disable-slash-commands` — the Psyche drives itself from the prompt; no slash surface.
95	///   * `--dangerously-skip-permissions` — REQUIRED, not cosmetic: the daemon spawns this runner
96	///     DETACHED with `Stdio::null` (see run()), so an interactive permission prompt would have no
97	///     operator/stdin to approve it and would HANG the turn. Auto-approve within the Read/Edit/Write
98	///     sandbox is the safe combination (bounded surface + no deadlock).
99	///   * model pin (`sonnet` primary, `opus` fallback, `medium` effort) — mirrors legacy so the cheap
100	///     companion does not silently ride the parent's heavier model.
101	/// [impl->REQ-SKILL-LIVE]
102	fn sandbox_flags() -> Vec<String> {
103	    [
104	        "--model",
105	        "sonnet",
106	        "--fallback-model",
107	        "opus",
108	        "--effort",
109	        "medium",
110	        "--dangerously-skip-permissions",
111	        "--disable-slash-commands",
112	        "--tools",
113	        "Read,Edit,Write",
114	    ]
115	    .iter()
116	    .map(|s| (*s).to_string())
117	    .collect()
118	}
119
120	/// argv for the one-shot SEED turn: establishes the Psyche's headless `claude` session in the cwd
121	/// (`{psyche_dir}`) from the daemon-supplied psyche prompt. `-p` is the headless/print mode; the
122	/// sandbox flags (see [`sandbox_flags`]) constrain it to the legacy Read/Edit/Write companion box.
123	fn seed_cmd(prompt: &str) -> Vec<String> {
124	    let mut v = vec!["-p".into(), prompt.into()];
125	    v.extend(sandbox_flags());
126	    v
127	}
128
129	/// argv for a per-PULSE turn: resume the seeded session (`--continue` picks the most-recent session
130	/// in the cwd) and feed the pulse body as the turn's prompt. The seeded role-prompt makes that
131	/// claude author a commune delta for this pulse. Carries the SAME sandbox flags as the seed so the
132	/// constraint holds for every turn, not just the first (legacy applies them to resume too).
133	fn pulse_cmd(pulse: &str) -> Vec<String> {
134	    let mut v = vec!["--continue".into(), "-p".into(), pulse.into()];
135	    v.extend(sandbox_flags());
136	    v
137	}
138
139	/// argv for one perch poll: `spt ready <id> --once` registers the perch (first call), drains the
140	/// spooled backlog, blocks for exactly one delivery, then exits — re-run to stay reachable
141	/// (ready.md). Non-zero exit = perch gone (session end) = our cue to stop.
142	fn poll_cmd(id: &str) -> Vec<String> {
143	    vec!["ready".into(), id.into(), "--once".into()]
144	}
145
146	/// A pulse body is actionable only if non-blank: `spt ready` may print just its `READY:<id>` online
147	/// signal (on stderr) or an empty drain, which must NOT trigger a (billed) claude turn.
148	fn is_actionable(pulse: &str) -> bool {
149	    !pulse.trim().is_empty()
150	}
151
152	#[cfg(not(test))]
153	fn run() -> ExitCode {
154	    let args = match Args::parse(std::env::args().skip(1)) {
155	        Ok(a) => a,
156	        Err(e) => {
157	            eprintln!("claude-spt-psyche: {e}");
158	            return ExitCode::from(2);
159	        }
160	    };
161
162	    // Seed the resident Psyche claude session (best-effort: if claude is absent the daemon's perch
163	    // poll still defines our lifetime; we don't abort the runner over one failed turn).
164	    let _ = Command::new("claude")
165	        .args(seed_cmd(&args.prompt))
166	        .stdin(Stdio::null())
167	        .status();
168
169	    // Resident pulse loop. The perch IS the lifecycle: when the daemon tears the LiveAgent down at
170	    // session end the perch closes, `spt ready --once` exits non-zero, and we return.
171	    loop {
172	        let out = match Command::new("spt").args(poll_cmd(&args.id)).output() {
173	            Ok(o) => o,
174	            Err(_) => return ExitCode::SUCCESS, // spt vanished — nothing left to serve
175	        };
176	        if !out.status.success() {
177	            return ExitCode::SUCCESS; // perch gone => session ended
178	        }
179	        let pulse = String::from_utf8_lossy(&out.stdout);
180	        if !is_actionable(&pulse) {
181	            continue; // online signal / empty drain — no turn
182	        }
183	        let _ = Command::new("claude")
184	            .args(pulse_cmd(&pulse))
185	            .stdin(Stdio::null())
186	            .status();
187	    }
188	}
189
190	#[cfg(not(test))]
191	fn main() -> ExitCode {
192	    run()
193	}
194
195	#[cfg(test)]
196	fn main() {}
197
198	// [unit->REQ-SKILL-LIVE]
199	#[cfg(test)]
200	mod tests {
201	    use super::*;
202
203	    fn argv(parts: &[&str]) -> Vec<String> {
204	        parts.iter().map(|s| s.to_string()).collect()
205	    }
206
207	    #[test]
208	    fn id_and_session_are_order_independent_prompt_is_terminal() {
209	        // --id/--session-id may precede --prompt in any order; --prompt is last and slurps the rest.
210	        let a = Args::parse(argv(&[
211	            "--session-id", "sess-9", "--id", "perri-psyche", "--prompt", "be the psyche",
212	        ]))
213	        .unwrap();
214	        assert_eq!(
215	            a,
216	            Args {
217	                id: "perri-psyche".into(),
218	                session_id: "sess-9".into(),
219	                prompt: "be the psyche".into(),
220	            }
221	        );
222	    }
223
224	    #[test]
225	    fn prompt_slurps_whitespace_split_tokens() {
226	        // spt-core whitespace-splits the substituted {psyche_prompt}; we must rejoin every trailing
227	        // token, not reject the second word as "unknown arg" (the v0.8.1 phantom-Psyche bug).
228	        let a = Args::parse(argv(&[
229	            "--id", "p-psyche", "--session-id", "s",
230	            "--prompt", "PSYCHE", "REVIVAL", "time:", "epoch-ms:123", "incoming", "event:", "(none)",
231	        ]))
232	        .unwrap();
233	        assert_eq!(a.id, "p-psyche");
234	        assert_eq!(a.session_id, "s");
235	        assert_eq!(a.prompt, "PSYCHE REVIVAL time: epoch-ms:123 incoming event: (none)");
236	    }
237
238	    #[test]
239	    fn missing_flag_is_an_error_not_a_default() {
240	        let e = Args::parse(argv(&["--id", "x", "--session-id", "s"])).unwrap_err();
241	        assert!(e.contains("--prompt"), "got: {e}");
242	    }
243
244	    #[test]
245	    fn unknown_arg_before_prompt_still_rejected() {
246	        // Greedy --prompt must not mask a genuine misfire earlier in argv.
247	        let e = Args::parse(argv(&["--id", "x", "--bogus", "v", "--prompt", "hi"])).unwrap_err();
248	        assert!(e.contains("unknown arg: --bogus"), "got: {e}");
249	    }
250
251	    #[test]
252	    fn flag_without_value_errors() {
253	        let e = Args::parse(argv(&["--id"])).unwrap_err();
254	        assert!(e.contains("--id expects a value"), "got: {e}");
255	    }
256
257	    #[test]
258	    fn unknown_arg_rejected() {
259	        let e = Args::parse(argv(&["--bogus", "v"])).unwrap_err();
260	        assert!(e.contains("unknown arg: --bogus"), "got: {e}");
261	    }
262
263	    #[test]
264	    fn seed_is_headless_print_of_the_prompt() {
265	        // -p <prompt> leads; sandbox flags follow.
266	        let c = seed_cmd("hello psyche");
267	        assert_eq!(&c[..2], &["-p", "hello psyche"]);
268	        assert_eq!(&c[2..], &sandbox_flags()[..]);
269	    }
270
271	    #[test]
272	    fn pulse_resumes_then_prints() {
273	        // --continue MUST precede -p so the turn resumes the seeded session rather than starting fresh.
274	        let c = pulse_cmd("commune now");
275	        assert_eq!(&c[..3], &["--continue", "-p", "commune now"]);
276	        assert_eq!(&c[3..], &sandbox_flags()[..]);
277	    }
278
279	    // [unit->REQ-HAZARD-PSYCHE-PERMS-DEADLOCK]
280	    #[test]
281	    fn every_turn_is_sandboxed_to_legacy_owl_parity() {
282	        // The Psyche is a constrained companion (claude_skill_owl parity): Read/Edit/Write only,
283	        // slash-commands off, and skip-permissions because the daemon spawns it detached/null-stdin
284	        // (an interactive prompt would deadlock). Both seed AND pulse must carry the full set.
285	        for cmd in [seed_cmd("seed"), pulse_cmd("pulse")] {
286	            assert!(cmd.windows(2).any(|w| w == ["--tools", "Read,Edit,Write"]), "tools cap missing: {cmd:?}");
287	            assert!(cmd.iter().any(|a| a == "--disable-slash-commands"), "slash-commands not disabled: {cmd:?}");
288	            assert!(cmd.iter().any(|a| a == "--dangerously-skip-permissions"), "skip-permissions missing (detached spawn would hang): {cmd:?}");
289	            assert!(cmd.windows(2).any(|w| w == ["--model", "sonnet"]), "model not pinned: {cmd:?}");
290	        }
291	    }
292
293	    #[test]
294	    fn poll_is_ready_once_on_the_psyche_perch() {
295	        assert_eq!(
296	            poll_cmd("perri-psyche"),
297	            vec!["ready", "perri-psyche", "--once"]
298	        );
299	    }
300
301	    #[test]
302	    fn blank_pulses_do_not_trigger_a_turn() {
303	        assert!(!is_actionable(""));
304	        assert!(!is_actionable("   \n\t "));
305	        assert!(is_actionable("READY backlog: resume the build"));
306	    }
307	}
308

//! claude-spt-psyche — the `[session.psyche_init]` Psyche runner for the claude-spt LiveAgent.
//!
//! WHAT A PSYCHE IS (doyle 2026-06-15, traced spt-core): a LiveAgent's detached companion. The
//! spt-core daemon's livehost hosts it — only when the resolved manifest declares
//! `[session.psyche_init]` AND the perch came up as state=live_agent (the live `api listen` path;
//! Option A 2026-06-17 — psyche_init lives in the BASE claude-spt manifest, no `:live` profile; a
//! ready/poll perch is skipped regardless, livehost.rs:282). The Psyche owns its OWN perch
//! (`<parent>-psyche`), receives daemon PULSES
//! on that perch, and on each pulse authors a COMMUNE delta (a context brief for the parent's
//! resume across `/clear`/compact) — it NEVER replies/notifies (the echo-commune is a distinct,
//! cheaper actor). It exits at session end. The Psyche is daemon-MANAGED by contract (decoupled
//! from message delivery); this runner does NOT orchestrate its own lifecycle — it is the dumb
//! resident wrapper the daemon launches and the daemon tears down (perch close = our exit signal).
//!
//! WHY A RESIDENT WRAPPER (not bare `claude -p`): `claude -p` is ONE-SHOT (one turn, then exits;
//! Stop hooks cannot re-loop it), and CC is migrating off `-p` billing-wise. So the Psyche-as-an-
//! LLM is a headless `claude` session in `{psyche_dir}` that we KEEP ALIVE: seed it once with the
//! daemon-supplied `{psyche_prompt}` (which tells that claude its Psyche role + the commune-on-pulse
//! contract), then drive one `claude --continue` turn per pulse. The COMMUNE authoring lives in
//! that claude (driven by the prompt) — this runner stays pure orchestration glue.
//!
//! Invoked by the daemon (detached, cwd=`{psyche_dir}`, stdio null):
//!   claude-spt-psyche --id <parent>-psyche --session-id <session_id> --prompt <psyche_prompt…>
//! ({id} is the daemon-OVERRIDDEN `<parent>-psyche`, NOT the parent endpoint id — see spawn_psyche.)
//! NOTE on `--prompt` parsing: spt-core **<0.8.2** substituted `{psyche_prompt}` into the command
//! STRING then whitespace-split it, so the multi-word prompt reached us as many trailing argv tokens.
//! spt-core **0.8.2+** fills each `{key}` as exactly ONE argv element (tokenize-then-fill — re-validated
//! 2026-06-17: the prompt arrives as a single element, newlines intact). We parse `--prompt` GREEDILY
//! (slurp the rest) anyway — defensive: it reconstructs a split prompt on <0.8.2 AND passes the single
//! 0.8.2 element through unchanged. Keep `--prompt` LAST in the command template. [impl->REQ-SKILL-LIVE]
//!
//! Loop:  seed `claude -p <prompt>`  ->  forever { `spt ready <id> --once` (blocks for one pulse;
//! its stdout is the pulse body) ; perch-closed => exit ; empty drain => next ; else feed the pulse
//! to `claude --continue -p <pulse>` }. The perch register + backlog drain are the `spt ready`
//! command's own job (ready.md), so no separate establish step.
//! [impl->REQ-SKILL-LIVE]

#[cfg(not(test))]
use std::process::{Command, ExitCode, Stdio};

/// Parsed launch args. Only the three daemon-filled `psyche_init.keys` we template
/// (`{id, session_id, psyche_prompt}`; `{psyche_dir}` is consumed as cwd by the daemon, not argv).
#[derive(Debug, PartialEq)]
struct Args {
    id: String,
    session_id: String,
    prompt: String,
}

impl Args {
    /// Parse `--id V --session-id V --prompt <rest…>`. `--id`/`--session-id` are single-value and
    /// order-independent; `--prompt` is TERMINAL and GREEDY — it slurps every remaining token as the
    /// prompt (rejoined with single spaces). History: spt-core **<0.8.2** substituted `{psyche_prompt}`
    /// into the `[session.psyche_init]` command STRING then whitespace-split it, so a multi-word prompt
    /// (it always is — "PSYCHE REVIVAL time: … incoming event: …") arrived as many tokens, NOT one; a
    /// non-greedy `--prompt` read only the first word and rejected the second as "unknown arg" → instant
    /// exit 2 → the daemon recorded a phantom hosted Psyche (diagnosed v0.8.1, 2026-06-16). spt-core
    /// **0.8.2** fixed it (fills each `{key}` as ONE argv element; re-validated 2026-06-17). Greedy is
    /// KEPT as defensive: with one element, `collect().join(" ")` returns it unchanged; with the old
    /// split, it reconstructs. Our manifest places `--prompt` last. Returns the offending flag on a
    /// missing value or an unknown arg seen BEFORE `--prompt`, so a real misfire is still loud.
    fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args, String> {
        let (mut id, mut session_id, mut prompt) = (None, None, None);
        let mut it = argv.into_iter();
        while let Some(flag) = it.next() {
            let want = |it: &mut dyn Iterator<Item = String>| {
                it.next().ok_or_else(|| format!("{flag} expects a value"))
            };
            match flag.as_str() {
                "--id" => id = Some(want(&mut it)?),
                "--session-id" => session_id = Some(want(&mut it)?),
                // Terminal + greedy: consume ALL remaining tokens (spt-core whitespace-split the prompt).
                "--prompt" => {
                    prompt = Some(it.by_ref().collect::<Vec<_>>().join(" "));
                }
                other => return Err(format!("unknown arg: {other}")),
            }
        }
        Ok(Args {
            id: id.ok_or("missing --id")?,
            session_id: session_id.ok_or("missing --session-id")?,
            prompt: prompt.ok_or("missing --prompt")?,
        })
    }
}

/// Tool/permission SANDBOX flags applied to EVERY psyche claude turn (seed + each pulse) — legacy
/// owl parity (claude_skill_owl `src/live/wrapper/claude.rs`, init/resume/final all carry this exact
/// set). The Psyche is a CONSTRAINED companion, not a general agent:
///   * `--tools Read,Edit,Write` — the commune authoring needs file IO only; NO Bash/network/etc.
///     (legacy ALSO scopes an `--agents owl-psyche` subagent to the same three; the session-level
///     `--tools` cap is the effective gate, and our role-prompt arrives via `-p`, not an embedded
///     `--agents` template, so we apply the cap directly rather than recreating the subagent).
///   * `--disable-slash-commands` — the Psyche drives itself from the prompt; no slash surface.
///   * `--dangerously-skip-permissions` — REQUIRED, not cosmetic: the daemon spawns this runner
///     DETACHED with `Stdio::null` (see run()), so an interactive permission prompt would have no
///     operator/stdin to approve it and would HANG the turn. Auto-approve within the Read/Edit/Write
///     sandbox is the safe combination (bounded surface + no deadlock).
///   * model pin (`sonnet` primary, `opus` fallback, `medium` effort) — mirrors legacy so the cheap
///     companion does not silently ride the parent's heavier model.
/// [impl->REQ-SKILL-LIVE]
fn sandbox_flags() -> Vec<String> {
    [
        "--model",
        "sonnet",
        "--fallback-model",
        "opus",
        "--effort",
        "medium",
        "--dangerously-skip-permissions",
        "--disable-slash-commands",
        "--tools",
        "Read,Edit,Write",
    ]
    .iter()
    .map(|s| (*s).to_string())
    .collect()
}

/// argv for the one-shot SEED turn: establishes the Psyche's headless `claude` session in the cwd
/// (`{psyche_dir}`) from the daemon-supplied psyche prompt. `-p` is the headless/print mode; the
/// sandbox flags (see [`sandbox_flags`]) constrain it to the legacy Read/Edit/Write companion box.
fn seed_cmd(prompt: &str) -> Vec<String> {
    let mut v = vec!["-p".into(), prompt.into()];
    v.extend(sandbox_flags());
    v
}

/// argv for a per-PULSE turn: resume the seeded session (`--continue` picks the most-recent session
/// in the cwd) and feed the pulse body as the turn's prompt. The seeded role-prompt makes that
/// claude author a commune delta for this pulse. Carries the SAME sandbox flags as the seed so the
/// constraint holds for every turn, not just the first (legacy applies them to resume too).
fn pulse_cmd(pulse: &str) -> Vec<String> {
    let mut v = vec!["--continue".into(), "-p".into(), pulse.into()];
    v.extend(sandbox_flags());
    v
}

/// argv for one perch poll: `spt ready <id> --once` registers the perch (first call), drains the
/// spooled backlog, blocks for exactly one delivery, then exits — re-run to stay reachable
/// (ready.md). Non-zero exit = perch gone (session end) = our cue to stop.
fn poll_cmd(id: &str) -> Vec<String> {
    vec!["ready".into(), id.into(), "--once".into()]
}

/// A pulse body is actionable only if non-blank: `spt ready` may print just its `READY:<id>` online
/// signal (on stderr) or an empty drain, which must NOT trigger a (billed) claude turn.
fn is_actionable(pulse: &str) -> bool {
    !pulse.trim().is_empty()
}

#[cfg(not(test))]
fn run() -> ExitCode {
    let args = match Args::parse(std::env::args().skip(1)) {
        Ok(a) => a,
        Err(e) => {
            eprintln!("claude-spt-psyche: {e}");
            return ExitCode::from(2);
        }
    };

    // Seed the resident Psyche claude session (best-effort: if claude is absent the daemon's perch
    // poll still defines our lifetime; we don't abort the runner over one failed turn).
    let _ = Command::new("claude")
        .args(seed_cmd(&args.prompt))
        .stdin(Stdio::null())
        .status();

    // Resident pulse loop. The perch IS the lifecycle: when the daemon tears the LiveAgent down at
    // session end the perch closes, `spt ready --once` exits non-zero, and we return.
    loop {
        let out = match Command::new("spt").args(poll_cmd(&args.id)).output() {
            Ok(o) => o,
            Err(_) => return ExitCode::SUCCESS, // spt vanished — nothing left to serve
        };
        if !out.status.success() {
            return ExitCode::SUCCESS; // perch gone => session ended
        }
        let pulse = String::from_utf8_lossy(&out.stdout);
        if !is_actionable(&pulse) {
            continue; // online signal / empty drain — no turn
        }
        let _ = Command::new("claude")
            .args(pulse_cmd(&pulse))
            .stdin(Stdio::null())
            .status();
    }
}

#[cfg(not(test))]
fn main() -> ExitCode {
    run()
}

#[cfg(test)]
fn main() {}

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

    fn argv(parts: &[&str]) -> Vec<String> {
        parts.iter().map(|s| s.to_string()).collect()
    }

    #[test]
    fn id_and_session_are_order_independent_prompt_is_terminal() {
        // --id/--session-id may precede --prompt in any order; --prompt is last and slurps the rest.
        let a = Args::parse(argv(&[
            "--session-id", "sess-9", "--id", "perri-psyche", "--prompt", "be the psyche",
        ]))
        .unwrap();
        assert_eq!(
            a,
            Args {
                id: "perri-psyche".into(),
                session_id: "sess-9".into(),
                prompt: "be the psyche".into(),
            }
        );
    }

    #[test]
    fn prompt_slurps_whitespace_split_tokens() {
        // spt-core whitespace-splits the substituted {psyche_prompt}; we must rejoin every trailing
        // token, not reject the second word as "unknown arg" (the v0.8.1 phantom-Psyche bug).
        let a = Args::parse(argv(&[
            "--id", "p-psyche", "--session-id", "s",
            "--prompt", "PSYCHE", "REVIVAL", "time:", "epoch-ms:123", "incoming", "event:", "(none)",
        ]))
        .unwrap();
        assert_eq!(a.id, "p-psyche");
        assert_eq!(a.session_id, "s");
        assert_eq!(a.prompt, "PSYCHE REVIVAL time: epoch-ms:123 incoming event: (none)");
    }

    #[test]
    fn missing_flag_is_an_error_not_a_default() {
        let e = Args::parse(argv(&["--id", "x", "--session-id", "s"])).unwrap_err();
        assert!(e.contains("--prompt"), "got: {e}");
    }

    #[test]
    fn unknown_arg_before_prompt_still_rejected() {
        // Greedy --prompt must not mask a genuine misfire earlier in argv.
        let e = Args::parse(argv(&["--id", "x", "--bogus", "v", "--prompt", "hi"])).unwrap_err();
        assert!(e.contains("unknown arg: --bogus"), "got: {e}");
    }

    #[test]
    fn flag_without_value_errors() {
        let e = Args::parse(argv(&["--id"])).unwrap_err();
        assert!(e.contains("--id expects a value"), "got: {e}");
    }

    #[test]
    fn unknown_arg_rejected() {
        let e = Args::parse(argv(&["--bogus", "v"])).unwrap_err();
        assert!(e.contains("unknown arg: --bogus"), "got: {e}");
    }

    #[test]
    fn seed_is_headless_print_of_the_prompt() {
        // -p <prompt> leads; sandbox flags follow.
        let c = seed_cmd("hello psyche");
        assert_eq!(&c[..2], &["-p", "hello psyche"]);
        assert_eq!(&c[2..], &sandbox_flags()[..]);
    }

    #[test]
    fn pulse_resumes_then_prints() {
        // --continue MUST precede -p so the turn resumes the seeded session rather than starting fresh.
        let c = pulse_cmd("commune now");
        assert_eq!(&c[..3], &["--continue", "-p", "commune now"]);
        assert_eq!(&c[3..], &sandbox_flags()[..]);
    }

    // [unit->REQ-HAZARD-PSYCHE-PERMS-DEADLOCK]
    #[test]
    fn every_turn_is_sandboxed_to_legacy_owl_parity() {
        // The Psyche is a constrained companion (claude_skill_owl parity): Read/Edit/Write only,
        // slash-commands off, and skip-permissions because the daemon spawns it detached/null-stdin
        // (an interactive prompt would deadlock). Both seed AND pulse must carry the full set.
        for cmd in [seed_cmd("seed"), pulse_cmd("pulse")] {
            assert!(cmd.windows(2).any(|w| w == ["--tools", "Read,Edit,Write"]), "tools cap missing: {cmd:?}");
            assert!(cmd.iter().any(|a| a == "--disable-slash-commands"), "slash-commands not disabled: {cmd:?}");
            assert!(cmd.iter().any(|a| a == "--dangerously-skip-permissions"), "skip-permissions missing (detached spawn would hang): {cmd:?}");
            assert!(cmd.windows(2).any(|w| w == ["--model", "sonnet"]), "model not pinned: {cmd:?}");
        }
    }

    #[test]
    fn poll_is_ready_once_on_the_psyche_perch() {
        assert_eq!(
            poll_cmd("perri-psyche"),
            vec!["ready", "perri-psyche", "--once"]
        );
    }

    #[test]
    fn blank_pulses_do_not_trigger_a_turn() {
        assert!(!is_actionable(""));
        assert!(!is_actionable("   \n\t "));
        assert!(is_actionable("READY backlog: resume the build"));
    }
}


U2 is the big refactor. Need cplugs marketplace/plugin identifiers for the new `post-update` subcommand + how package-adapter handles binaries. Gather facts:


cd "C:/Users/decid/Documents/projects/spt-claude-code" && echo "=== plugin.json ==="; cat plugin/sptc/.claude-plugin/plugin.json 2>/dev/null || find plugin -name plugin.json -exec cat {} \;; echo "=== marketplace refs ==="; grep -rniE "cplugs|marketplace|plugin (add|update|install)" --include=*.md --include=*.sh --include=*.json plugin ci docs/RELEASE-RUNBOOK.md 2>/dev/null | grep -iv "reload-plugins" | head -30

Find cplugs marketplace + plugin identifiers


=== plugin.json ===
{
  "name": "sptc",
  "version": "0.1.6",
  "description": "Spacetime (spt) adapter for Claude Code — agent messaging, live agents, and spt-core bootstrap. Thin skeleton; logic rides the spt binary + adapter manifest (spt-core-conducted).",
  "author": {
    "name": "perri"
  },
  "keywords": ["spt", "spacetime", "messaging", "agents", "live", "claude-spt"]
}
=== marketplace refs ===
plugin/sptc/skills/version/SKILL.md:4:  Report the spt-core-tracked adapter version (the version-of-truth, not the marketplace plugin
ci/psyche-download/psyche-download-int.sh:9:# and injecting it into a live CC — genuinely needs the NEW plugin installed + a real CC session, so
ci/publish/package-skeleton.sh:2:# Stage the sptc cplugs SKELETON into a marketplace clone — the runbook "per skeleton bump" cp
ci/publish/package-skeleton.sh:4:# nothing. Never pushes (marketplace credentials + the commit/push are the operator's step).
ci/publish/package-skeleton.sh:10:#   MARKET=/path/to/cplugs/plugins/sptc package-skeleton.sh --apply
ci/publish/package-skeleton.sh:15:MARKET="${MARKET:-$HOME/.claude/plugins/marketplaces/cplugs/plugins/sptc}"
ci/publish/package-skeleton.sh:26:# The skeleton SUBSET — exactly what cplugs carries (no binary, no manifest, no runtime state).
ci/publish/package-skeleton.sh:40:  echo "cplugs repo and 'claude plugin install sptc@cplugs' (see docs/RELEASE-RUNBOOK.md)."
ci/publish/package-skeleton.sh:46:# Replace dir contents wholesale so removed skeleton files don't linger in the marketplace clone.
ci/publish/package-skeleton.sh:53:echo "STAGED into $MARKET. Next (operator): cd into the cplugs repo, 'git add plugins/sptc/',"
ci/publish/validate-skeleton.sh:2:# Validate the sptc cplugs SKELETON is a coherent, installable artifact carrying ONLY the skeleton
ci/publish/validate-skeleton.sh:4:# cplugs; see docs/RELEASE-RUNBOOK.md). Deterministic, binary pass/fail. [impl->REQ-DIST-PLUGIN-SKELETON]
ci/publish/validate-skeleton.sh:80:  printf 'FAIL: non-skeleton files in the published surface (runtime state / binary / manifest belong in the spt-core registry, not cplugs):\n%s\n' "$leaks"
ci/run-gates.sh:53:gate "skeleton-validate (cplugs installability)"
docs/RELEASE-RUNBOOK.md:14:1. **The `SaberMage/cplugs` marketplace** — the thin **skeleton plugin** (namespaced
docs/RELEASE-RUNBOOK.md:27:no release keys here. The only setup is marketplace/registry publish credentials for the two
docs/RELEASE-RUNBOOK.md:28:targets above (a `cplugs` push token + whatever the spt-core adapter-registry publish step
docs/RELEASE-RUNBOOK.md:51:   - **Skeleton plugin** → push to the `SaberMage/cplugs` marketplace **only when the plugin
docs/RELEASE-RUNBOOK.md:53:     `plugin.json`). Most releases do not bump the marketplace.
docs/RELEASE-RUNBOOK.md:58:## cplugs skeleton publish — concrete mechanics
docs/RELEASE-RUNBOOK.md:61:The concrete cplugs marketplace steps, captured from the sister project's
docs/RELEASE-RUNBOOK.md:62:`claude_skill_owl/docs/DEPLOY.md`. **Take only the SKELETON SUBSET.** spt-claude-code's cplugs
docs/RELEASE-RUNBOOK.md:64:bootstrap, `plugin.json`. **No binary, no manifest in cplugs** — the binary + adapter manifest
docs/RELEASE-RUNBOOK.md:74:> default**, stages only the skeleton subset, never pushes (the marketplace commit/push stays the
docs/RELEASE-RUNBOOK.md:77:**One-time marketplace setup:**
docs/RELEASE-RUNBOOK.md:80:# Clone the marketplace repo
docs/RELEASE-RUNBOOK.md:81:git clone https://github.com/SaberMage/cplugs.git ~/.claude/plugins/marketplaces/cplugs
docs/RELEASE-RUNBOOK.md:82:# Register a "cplugs" entry in ~/.claude/plugins/known_marketplaces.json
docs/RELEASE-RUNBOOK.md:83:#   source: { source: "github", repo: "SaberMage/cplugs" }
docs/RELEASE-RUNBOOK.md:91:# 2. Copy skeleton files (NO binary) into the marketplace clone:


Have all facts. Building the consolidated `claude-spt` crate (hand-rolled subcommand dispatch — honors the crates' deliberate dependency-light ethos over adding clap; I'll note the deviation). Creating the crate files:


Write

# claude-spt — the consolidated tool binary for the claude-spt adapter (ADR-0006, U2).
# Merges the former claude-spt-digest + claude-spt-psyche crates into ONE binary with subcommands
# (`claude-spt digest` / `claude-spt psyche` / `claude-spt post-update`), so the fat adapter.spt
# carries fewer artifacts per triple and the seams share one dep tree. Rust for the same reasons as
# its predecessors: a compiled, dependency-light binary the spt-core daemon can spawn BARE on
# Win+Linux with no interpreter-on-PATH or locale-encoding hazard (stdout is always UTF-8).
#
# Subcommand dispatch is hand-rolled (a bare argv[1] match), NOT clap: both predecessor crates were
# deliberately dependency-light (psyche was std-only; digest carried serde_json alone), and a flat
# three-way dispatch needs no arg-parsing framework. Each subcommand keeps its own hand-written flag
# parser (verbatim from its origin crate). [impl->REQ-DIST-BINARY-CONSOLIDATE]
#
# Builds via ci/digest/build.sh + ci/psyche/build.sh (both now build THIS crate), gated in
# ci/run-gates.sh; packed into adapter.spt by ci/publish/package-adapter.sh.
[package]
name = "claude-spt"
version = "0.1.0"
edition = "2021"
publish = false

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

[dependencies]
serde_json = "1"

[profile.release]
strip = true


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

# claude-spt — the consolidated tool binary for the claude-spt adapter (ADR-0006, U2).
# Merges the former claude-spt-digest + claude-spt-psyche crates into ONE binary with subcommands
# (`claude-spt digest` / `claude-spt psyche` / `claude-spt post-update`), so the fat adapter.spt
# carries fewer artifacts per triple and the seams share one dep tree. Rust for the same reasons as
# its predecessors: a compiled, dependency-light binary the spt-core daemon can spawn BARE on
# Win+Linux with no interpreter-on-PATH or locale-encoding hazard (stdout is always UTF-8).
#
# Subcommand dispatch is hand-rolled (a bare argv[1] match), NOT clap: both predecessor crates were
# deliberately dependency-light (psyche was std-only; digest carried serde_json alone), and a flat
# three-way dispatch needs no arg-parsing framework. Each subcommand keeps its own hand-written flag
# parser (verbatim from its origin crate). [impl->REQ-DIST-BINARY-CONSOLIDATE]
#
# Builds via ci/digest/build.sh + ci/psyche/build.sh (both now build THIS crate), gated in
# ci/run-gates.sh; packed into adapter.spt by ci/publish/package-adapter.sh.
[package]
name = "claude-spt"
version = "0.1.0"
edition = "2021"
publish = false

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

[dependencies]
serde_json = "1"

[profile.release]
strip = true


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

mod digest;
mod post_update;
mod psyche;

use std::process::ExitCode;

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

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

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

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

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

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

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

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