1	---
2	created: 2026-04-21T12:55:04.699Z
3	updated: 2026-05-10T00:00:00.000Z
4	title: Sync ccs-instance settings.json OWL/LIVE paths (CLAUDE_CONFIG_DIR-aware)
5	area: tooling
6	files:
7	  - src/owl/plugin_session_start.rs (sync_settings_json — line 180)
8	  - src/common/owlery.rs (CLAUDE_CONFIG_DIR resolver — line 197)
9	---
10
11	## Status
12
13	Narrowed 2026-05-10 from original "Rebounder executable trampolines OWL/LIVE to latest spt binary."
14
15	The bulk of the original problem is **already solved** by:
16	- `version_trampoline()` (`src/owl/plugin_session_start.rs:303`) — SessionStart scans sibling cache dirs, picks newest semver, rewrites `CLAUDE_ENV_FILE` `OWL`/`LIVE`, backfills binary + hooks.json into old version dir.
17	- `sync_settings_json()` (`src/owl/plugin_session_start.rs:180`, commit `16e4338` quick-260415-ujn) — mirrors trampolined paths into `~/.claude/settings.json` for Bash-tool / settings-json-reading consumers.
18	- Per-session `CLAUDE_ENV_FILE` env var refresh works in **any** profile (main + ccs instances).
19
20	The rebounder-as-third-binary design was abandoned in favor of the above in-binary approach. Remaining gap is narrow.
21
22	## Problem
23
24	`sync_settings_json()` hardcodes `USERPROFILE`/`HOME` (line 198) → writes only `~/.claude/settings.json`. Does **not** honor `CLAUDE_CONFIG_DIR`, so ccs-instance `settings.json` (e.g. `~/.ccs/instances/bigscreen/.claude/settings.json`) is **never** synced to the trampolined paths.
25
26	Combined with the known `CLAUDE_ENV_FILE`-not-sourced-on-Windows bug (Anthropic #27987, memory: `claude_env_file_windows_bug.md`), the Bash tool inside a ccs profile on Windows still reads stale `OWL`/`LIVE` from the ccs settings.json — reproducing the original `1.8.11`-cache-deleted failure mode.
27
28	## Solution
29
30	Make `sync_settings_json()` `CLAUDE_CONFIG_DIR`-aware, mirroring the resolver pattern in `src/common/owlery.rs:197`:
31
32	1. If `CLAUDE_CONFIG_DIR` is set + non-empty → write to `${CLAUDE_CONFIG_DIR}/settings.json` (ccs-instance path)
33	2. Otherwise → fall back to `${HOME}/.claude/settings.json` (current behavior)
34
35	Optionally: also write the main `~/.claude/settings.json` even when in a ccs context (belt-and-suspenders), so a user toggling between ccs and main keeps both in sync.
36
37	## Acceptance
38
39	- Run a fresh ccs-instance Claude Code session against a stale `OWL` in ccs settings.json; SessionStart trampolines + syncs ccs settings.json to current cache path.
40	- Bash tool invocation of `$OWL list` in ccs profile on Windows succeeds without manual env-var override.
41	- Main `~/.claude/settings.json` sync behavior unchanged for non-ccs sessions.
42
43	## Related
44
45	- Phase 18.8.1 — ccs-aware path resolution (echo-commune jsonl + owlery root); sets the precedent for `CLAUDE_CONFIG_DIR` honoring.
46	- `src/common/owlery.rs:197` — resolver to copy.
47	- Memory: `project_claude_env_file_windows_bug.md` — explains why settings.json sync (not just CLAUDE_ENV_FILE) is needed for Bash on Windows.
48	- Original 2026-04-21 todo (rebounder trampoline executable) — superseded by in-binary trampoline; this is the residual gap.
49

---
created: 2026-04-21T12:55:04.699Z
updated: 2026-05-10T00:00:00.000Z
title: Sync ccs-instance settings.json OWL/LIVE paths (CLAUDE_CONFIG_DIR-aware)
area: tooling
files:
  - src/owl/plugin_session_start.rs (sync_settings_json — line 180)
  - src/common/owlery.rs (CLAUDE_CONFIG_DIR resolver — line 197)
---

## Status

Narrowed 2026-05-10 from original "Rebounder executable trampolines OWL/LIVE to latest spt binary."

The bulk of the original problem is **already solved** by:
- `version_trampoline()` (`src/owl/plugin_session_start.rs:303`) — SessionStart scans sibling cache dirs, picks newest semver, rewrites `CLAUDE_ENV_FILE` `OWL`/`LIVE`, backfills binary + hooks.json into old version dir.
- `sync_settings_json()` (`src/owl/plugin_session_start.rs:180`, commit `16e4338` quick-260415-ujn) — mirrors trampolined paths into `~/.claude/settings.json` for Bash-tool / settings-json-reading consumers.
- Per-session `CLAUDE_ENV_FILE` env var refresh works in **any** profile (main + ccs instances).

The rebounder-as-third-binary design was abandoned in favor of the above in-binary approach. Remaining gap is narrow.

## Problem

`sync_settings_json()` hardcodes `USERPROFILE`/`HOME` (line 198) → writes only `~/.claude/settings.json`. Does **not** honor `CLAUDE_CONFIG_DIR`, so ccs-instance `settings.json` (e.g. `~/.ccs/instances/bigscreen/.claude/settings.json`) is **never** synced to the trampolined paths.

Combined with the known `CLAUDE_ENV_FILE`-not-sourced-on-Windows bug (Anthropic #27987, memory: `claude_env_file_windows_bug.md`), the Bash tool inside a ccs profile on Windows still reads stale `OWL`/`LIVE` from the ccs settings.json — reproducing the original `1.8.11`-cache-deleted failure mode.

## Solution

Make `sync_settings_json()` `CLAUDE_CONFIG_DIR`-aware, mirroring the resolver pattern in `src/common/owlery.rs:197`:

1. If `CLAUDE_CONFIG_DIR` is set + non-empty → write to `${CLAUDE_CONFIG_DIR}/settings.json` (ccs-instance path)
2. Otherwise → fall back to `${HOME}/.claude/settings.json` (current behavior)

Optionally: also write the main `~/.claude/settings.json` even when in a ccs context (belt-and-suspenders), so a user toggling between ccs and main keeps both in sync.

## Acceptance

- Run a fresh ccs-instance Claude Code session against a stale `OWL` in ccs settings.json; SessionStart trampolines + syncs ccs settings.json to current cache path.
- Bash tool invocation of `$OWL list` in ccs profile on Windows succeeds without manual env-var override.
- Main `~/.claude/settings.json` sync behavior unchanged for non-ccs sessions.

## Related

- Phase 18.8.1 — ccs-aware path resolution (echo-commune jsonl + owlery root); sets the precedent for `CLAUDE_CONFIG_DIR` honoring.
- `src/common/owlery.rs:197` — resolver to copy.
- Memory: `project_claude_env_file_windows_bug.md` — explains why settings.json sync (not just CLAUDE_ENV_FILE) is needed for Bash on Windows.
- Original 2026-04-21 todo (rebounder trampoline executable) — superseded by in-binary trampoline; this is the residual gap.


Key finding: ccs relocates claude's root via **`CLAUDE_CONFIG_DIR`** (e.g. `~/.ccs/instances/<acct>/.claude`). Read the known-good resolver.


180	    /// Phase 35 D-03 — total stale-rebase auto-recoveries since sync was last
181	    /// enabled. `abort_stale_rebase` bumps this whenever it actually fires
182	    /// `git rebase --abort`. `#[serde(default)]` keeps older settings.json
183	    /// files (written before this field existed) loading cleanly with 0.
184	    #[serde(default)]
185	    pub recovered_aborts: u32,
186	    #[serde(default)]
187	    pub last_failure_ts: Option<String>,
188	    #[serde(default)]
189	    pub last_failure_reason: Option<String>,
190	    #[serde(default)]
191	    pub next_retry_after_ts: Option<String>,
192	}
193
194	impl Default for SyncSettings {
195	    fn default() -> Self {
196	        Self {
197	            state: SyncState::Unset,
198	            remote_url: None,
199	            last_prompted_ts: None,
200	            remind_after_ts: None,
201	            acked_ts: None,
202	            consecutive_failures: 0,
203	            recovered_aborts: 0,
204	            last_failure_ts: None,
205	            last_failure_reason: None,
206	            next_retry_after_ts: None,
207	        }
208	    }
209	}
210
211	/// Returns the path to the sync settings file: `$SPT_HOME/settings.json`.
212	///
213	/// Target is `$SPT_HOME/settings.json` (NOT `~/.claude/settings.json` — see
214	/// Phase 35 RESEARCH Pitfall 7). The sync namespace lives in the SPT runtime
215	/// root, separate from the claude-code-managed file maintained by
216	/// `crate::common::auto_setup`.
217	pub fn sync_settings_path() -> PathBuf {
218	    spt_home().join("settings.json")
219	}
220
221	/// Reads the `sync` namespace from `$SPT_HOME/settings.json`.
222	///
223	/// Returns `SyncSettings::default()` on ANY failure (missing file, invalid
224	/// JSON, missing `sync` key, shape mismatch). Never panics, never prints to
225	/// stderr — this runs on hot paths and must soft-fail silently (D-14).
226	pub fn read_sync_settings() -> SyncSettings {
227	    let content = match fs::read_to_string(sync_settings_path()) {
228	        Ok(c) => c,
229	        Err(_) => return SyncSettings::default(),
230	    };
231	    let value: serde_json::Value = match serde_json::from_str(&content) {
232	        Ok(v) => v,
233	        Err(_) => return SyncSettings::default(),
234	    };
235	    let sync_value = match value.get(SYNC_SETTINGS_KEY) {
236	        Some(v) => v,
237	        None => return SyncSettings::default(),
238	    };
239	    match serde_json::from_value::<SyncSettings>(sync_value.clone()) {

    /// Phase 35 D-03 — total stale-rebase auto-recoveries since sync was last
    /// enabled. `abort_stale_rebase` bumps this whenever it actually fires
    /// `git rebase --abort`. `#[serde(default)]` keeps older settings.json
    /// files (written before this field existed) loading cleanly with 0.
    #[serde(default)]
    pub recovered_aborts: u32,
    #[serde(default)]
    pub last_failure_ts: Option<String>,
    #[serde(default)]
    pub last_failure_reason: Option<String>,
    #[serde(default)]
    pub next_retry_after_ts: Option<String>,
}

impl Default for SyncSettings {
    fn default() -> Self {
        Self {
            state: SyncState::Unset,
            remote_url: None,
            last_prompted_ts: None,
            remind_after_ts: None,
            acked_ts: None,
            consecutive_failures: 0,
            recovered_aborts: 0,
            last_failure_ts: None,
            last_failure_reason: None,
            next_retry_after_ts: None,
        }
    }
}

/// Returns the path to the sync settings file: `$SPT_HOME/settings.json`.
///
/// Target is `$SPT_HOME/settings.json` (NOT `~/.claude/settings.json` — see
/// Phase 35 RESEARCH Pitfall 7). The sync namespace lives in the SPT runtime
/// root, separate from the claude-code-managed file maintained by
/// `crate::common::auto_setup`.
pub fn sync_settings_path() -> PathBuf {
    spt_home().join("settings.json")
}

/// Reads the `sync` namespace from `$SPT_HOME/settings.json`.
///
/// Returns `SyncSettings::default()` on ANY failure (missing file, invalid
/// JSON, missing `sync` key, shape mismatch). Never panics, never prints to
/// stderr — this runs on hot paths and must soft-fail silently (D-14).
pub fn read_sync_settings() -> SyncSettings {
    let content = match fs::read_to_string(sync_settings_path()) {
        Ok(c) => c,
        Err(_) => return SyncSettings::default(),
    };
    let value: serde_json::Value = match serde_json::from_str(&content) {
        Ok(v) => v,
        Err(_) => return SyncSettings::default(),
    };
    let sync_value = match value.get(SYNC_SETTINGS_KEY) {
        Some(v) => v,
        None => return SyncSettings::default(),
    };
    match serde_json::from_value::<SyncSettings>(sync_value.clone()) {


5-use std::fs;
6-
7-/// Returns the spt runtime root directory, creating it if missing.
8-/// (Formerly `spacetime_dir()` — renamed in Phase 18.2 when the runtime
9:/// root was relocated out of `~/.claude/` to escape Claude Code's
10-/// workspace permission block.)
11-///
12-\ Resolution order:
13-///   1. `$SPT_HOME` (if set and non-empty) — used by tests and power users
--
105-pub fn agent_worktree_path(agent_id: &str) -> PathBuf {
106-    tracked_root().join("agents").join(agent_id)
107-}
108-
109:/// D-04/D-16: project worktree path — `psyches/tracked/projects/{project_name}/`.
110-/// Prefix-less per D-04. Does NOT create the directory.
111-pub fn project_worktree_path(project_name: &str) -> PathBuf {
112:    tracked_root().join("projects").join(project_name)
113-}
114-
115-\ D-04: mechanical agent-folder → branch mapping. `agents/doyle` → `a-doyle`.
116-pub fn agent_branch(agent_id: &str) -> String {
117-    format!("a-{}", agent_id)
118-}
119-
120:/// D-04: mechanical project-folder → branch mapping. `projects/X` → `p-X`.
121-pub fn project_branch(project_name: &str) -> String {
122-    format!("p-{}", project_name)
123-}
124-
--
126-// Phase 35 — psyche-sync settings.json data layer (D-11)
127-// =============================================================================
128-//
129-// The sync namespace persists under `$SPT_HOME/settings.json` (NOT
130:// `~/.claude/settings.json` — see Phase 35 RESEARCH Pitfall 7). This is the
131-// SPT runtime root file, distinct from the claude-code-managed file maintained
132-\ by `crate::common::auto_setup`. Every other Phase 35 plan reads/mutates this
133-// state via the three helpers below; this is a dependency-free leaf.
134-
--
209-}
210-
211-\ Returns the path to the sync settings file: `$SPT_HOME/settings.json`.
212-///
213:/// Target is `$SPT_HOME/settings.json` (NOT `~/.claude/settings.json` — see
214-/// Phase 35 RESEARCH Pitfall 7). The sync namespace lives in the SPT runtime
215-/// root, separate from the claude-code-managed file maintained by
216-\ `crate::common::auto_setup`.
217-pub fn sync_settings_path() -> PathBuf {
--
397-pub fn echo_input_path(epoch: i64) -> PathBuf {
398-    psyche_dir().join(format!("echo-input-{}.jsonl", epoch))
399-}
400-
401:/// Encode an absolute filesystem path to Claude Code's `~/.claude/projects/<name>` form
402-/// (Phase 18.8 D-07).
403-///
404-\ Algorithm: every character outside `[A-Za-z0-9-]` becomes `-`. No lowercasing. No
405-/// collapsing of consecutive dashes. Empirically reverse-engineered from 13 on-machine
406-/// directories during Phase 18.8 research.
407-///
408:/// Example: `C:\Users\decid\Documents\projects\claude_skill_owl`
409:///       -> `C--Users-decid-Documents-projects-claude-skill-owl`
410-pub fn encode_project_path(abs: &std::path::Path) -> String {
411-    let s = abs.to_string_lossy();
412-    let mut out = String::with_capacity(s.len());
413-    for ch in s.chars() {
--
419-    }
420-    out
421-}
422-
423:/// Returns the Claude Code projects directory root, honoring the ccs
424:/// `CLAUDE_CONFIG_DIR` env var before falling back to `$HOME/.claude/projects`
425:/// (Unix) or `$USERPROFILE/.claude/projects` (Windows).
426-///
427-\ Resolution order:
428:///   1. `$CLAUDE_CONFIG_DIR/projects` if the env var is set AND non-empty
429-///      (ccs / custom config relocates Claude Code's state tree here)
430:///   2. Platform default: `$HOME/.claude/projects` (Unix) or
431:///      `$USERPROFILE/.claude/projects` (Windows)
432-///
433-/// Does NOT create the directory. Callers that need the dir to exist must
434-\ `fs::create_dir_all` themselves. Phase 18.8.1 callers (echo-commune
435-/// extractor) only READ jsonls under this root, so creation is not needed.
436-///
437-/// Phase 18.8.1 REQ-CCS-01. See
438-/// `.planning/debug/witty-echo-commune-empty-excerpt-post-18.8.md`.
439:pub fn claude_projects_root() -> PathBuf {
440:    if let Ok(cfg) = std::env::var("CLAUDE_CONFIG_DIR") {
441-        if !cfg.is_empty() {
442:            return PathBuf::from(cfg).join("projects");
443-        }
444-    }
445:    // Build `<home>/.claude/projects` via component iterator rather than
446:    // chained `.join(".claude").join("projects")` — the Task-3 crate-wide
447:    // audit rejects both the contiguous `.claude/projects` literal AND the
448:    // two-step-join form. This is the canonical claude projects derivation;
449-    // every other call site in src/ MUST route through this function.
450-    let mut p = claude_home_fallback();
451:    p.extend([".claude", "projects"].iter());
452-    p
453-}
454-
455-#[cfg(windows)]
456:fn claude_home_fallback() -> PathBuf {
457-    std::env::var("USERPROFILE")
458-        .map(PathBuf::from)
459:        .expect("USERPROFILE must be set on Windows when CLAUDE_CONFIG_DIR unset")
460-}
461-
462-#[cfg(unix)]
463:fn claude_home_fallback() -> PathBuf {
464-    std::env::var("HOME")
465-        .map(PathBuf::from)
466:        .expect("HOME must be set on Unix when CLAUDE_CONFIG_DIR unset")
467-}
468-
469-/// Atomic write of a single string body to `path` via a sibling `{name}.tmp` file
470-\ followed by `fs::rename` (Phase 18.8 D-14).
--
1013-///
1014-/// **Phase 35.3-02 / Issue 7 (D-09..D-11) guarded-fallback contract.** A live
1015-/// agent whose host project cwd is NOT a git repository must still resolve a
1016-/// project name so its tracked context slice
1017:/// (`psyches/tracked/projects/<name>/`) gets a worktree, a commit, and is
1018-/// synced cross-machine. The tracked worktree lives in the shared `seed/` repo
1019-/// and is git-versioned regardless of host git status, so the prior strict
1020-/// git-ancestor-or-`None` contract was an over-correction (it gated tracked
1021-\ sync on host-repo presence). Resolution is now:
--
1722-\ 25.3-04 cycle-4 HIGH 1 + cycle-5 HIGH 1: marker-stripping read helper.
1723-// =============================================================================
1724-
1725-\ 25.3-04 cycle-4 HIGH 1 + cycle-5 HIGH 1 fix: read a context file
1726:/// (`live_context.md` or `projects/<name>/<self_id>.md`) and strip the SPT
1727-/// precedence marker from EITHER the post-YAML position OR line 1, returning
1728-/// the body in the shape that prompt builders expect.
1729-///
1730-\ Marker format (written by `route_two_slice_with_precedence`):
--
1847-                spt_home: std::env::var("SPT_HOME").ok(),
1848-                localappdata: std::env::var("LOCALAPPDATA").ok(),
1849-                home: std::env::var("HOME").ok(),
1850-                userprofile: std::env::var("USERPROFILE").ok(),
1851:                claude_config_dir: std::env::var("CLAUDE_CONFIG_DIR").ok(),
1852-            }
1853-        }
1854-    }
1855-
--
1870-            match &self.userprofile {
1871-                Some(v) => std::env::set_var("USERPROFILE", v),
1872-                None => std::env::remove_var("USERPROFILE"),
1873-            }
1874:            match &self.claude_config_dir {
1875:                Some(v) => std::env::set_var("CLAUDE_CONFIG_DIR", v),
1876:                None => std::env::remove_var("CLAUDE_CONFIG_DIR"),
1877-            }
1878-        }
1879-    }
1880-
--
1966-    #[test]
1967-    fn encode_project_path_windows_canonical() {
1968-        assert_eq!(
1969-            encode_project_path(std::path::Path::new(
1970:                r"C:\Users\decid\Documents\projects\claude_skill_owl"
1971-            )),
1972:            "C--Users-decid-Documents-projects-claude-skill-owl"
1973-        );
1974-    }
1975-
1976-    #[test]
1977-    fn encode_project_path_hidden_dir_collision() {
1978-        // `\.` = two non-alnum chars = two dashes (NO collapsing)
1979-        assert_eq!(
1980-            encode_project_path(std::path::Path::new(
1981:                r"C:\Users\decid\.claude\spacetime\psyches\tracked"
1982-            )),
1983-            "C--Users-decid--claude-spacetime-psyches-tracked"
1984-        );
1985-    }
--
1987-    #[test]
1988-    fn encode_project_path_underscore_becomes_dash() {
1989-        assert_eq!(
1990-            encode_project_path(std::path::Path::new(
1991:                r"C:\Users\decid\Documents\projects\eyetrack_mipi_fpga"
1992-            )),
1993:            "C--Users-decid-Documents-projects-eyetrack-mipi-fpga"
1994-        );
1995-    }
1996-
1997-    #[test]
--
2054-        let p = last_commune_epoch_path("testagent");
2055-        assert_eq!(std::fs::read_to_string(&p).unwrap(), "1713600000");
2056-    }
2057-
2058:    // === Phase 18.8.1 claude_projects_root() tests (REQ-CCS-01) ===
2059-
2060-    #[test]
2061:    fn claude_projects_root_honors_ccs_env() {
2062-        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2063-        let _snap = EnvSnapshot::capture();
2064:        std::env::set_var("CLAUDE_CONFIG_DIR", "/tmp/ccs-fake-18-8-1");
2065:        let got = claude_projects_root();
2066:        assert_eq!(got, PathBuf::from("/tmp/ccs-fake-18-8-1").join("projects"));
2067-    }
2068-
2069-    #[test]
2070:    fn claude_projects_root_falls_back_to_home() {
2071-        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2072-        let _snap = EnvSnapshot::capture();
2073:        std::env::remove_var("CLAUDE_CONFIG_DIR");
2074-        #[cfg(windows)]
2075-        std::env::set_var("USERPROFILE", r"C:\fake-home-18-8-1");
2076-        #[cfg(unix)]
2077-        std::env::set_var("HOME", "/tmp/fake-home-18-8-1");
2078:        let got = claude_projects_root();
2079-        // Build expected via component iterator to avoid triggering the
2080:        // Task-3 crate-wide audit for runtime hardcoded `.claude/projects`
2081-        // derivations. Test asserts that the helper-built path equals the
2082-        // composed components.
2083-        #[cfg(windows)]
2084:        let expected: PathBuf = [r"C:\fake-home-18-8-1", ".claude", "projects"]
2085-            .iter().collect();
2086-        #[cfg(unix)]
2087:        let expected: PathBuf = ["/tmp/fake-home-18-8-1", ".claude", "projects"]
2088-            .iter().collect();
2089-        assert_eq!(got, expected);
2090-    }
2091-
2092-    #[test]
2093:    fn claude_projects_root_ignores_empty_env() {
2094-        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2095-        let _snap = EnvSnapshot::capture();
2096:        std::env::set_var("CLAUDE_CONFIG_DIR", "");
2097-        #[cfg(windows)]
2098-        std::env::set_var("USERPROFILE", r"C:\fake-home-empty-18-8-1");
2099-        #[cfg(unix)]
2100-        std::env::set_var("HOME", "/tmp/fake-home-empty-18-8-1");
2101:        let got = claude_projects_root();
2102:        // Component-iterator build (see claude_projects_root_falls_back_to_home).
2103-        #[cfg(windows)]
2104:        let expected: PathBuf = [r"C:\fake-home-empty-18-8-1", ".claude", "projects"]
2105-            .iter().collect();
2106-        #[cfg(unix)]

[Showing results with pagination = limit: 250]


Fully validated against known-good owl. The pattern:
- **Spawn:** `ccs` is a drop-in for the `claude` binary (owl swaps `cli_binary` claude→ccs, same args).
- **Transcript root:** ccs relocates via **`CLAUDE_CONFIG_DIR`**; owl's canonical resolver `claude_projects_root()` = `$CLAUDE_CONFIG_DIR/projects` if set, else `$HOME/.claude/projects`. The env-awareness lives **in the binary**, not a static path.

This maps cleanly: the ccs digest seam belongs in our `claude-spt-digest` extractor (honor `CLAUDE_CONFIG_DIR`), not a manifest leaf (can't express env). Check current extractor.


1	//! claude-spt-digest — the `[digest]` session-digest extractor for the claude-spt adapter (ADR-0019).
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	//! Mapping (CC event -> digest record):
20	//!   type=user,  message.content str         -> {role:input, text}              (a real user prompt)
21	//!   type=user,  content[].type=text         -> {role:input, text}
22	//!   type=assistant, content[].type=text     -> {role:agent, text}
23	//!   type=assistant, content[].type=tool_use -> {role:tool,  tool:{name,arg}}
24	//!   thinking / tool_result / attachment / queue-operation / last-prompt -> skipped (not emitted)
25	//! [impl->REQ-DIST-DIGEST-EXTRACTOR]
26
27	use serde_json::{json, Value};
28	use std::fs::{self, File};
29	use std::io::{self, BufRead, BufReader, BufWriter, Write};
30	use std::path::{Path, PathBuf};
31	use std::process::ExitCode;
32
33	/// Single salient arg per tool call (spt-core truncates to arg_truncation). Preference order picks
34	/// the most digest-meaningful field; falls back to the first scalar, then compact JSON of the input.
35	const ARG_KEYS: &[&str] = &[
36	    "file_path", "path", "notebook_path", "command", "pattern", "query", "url", "prompt",
37	    "old_string", "description", "subagent_type",
38	];
39
40	fn scalar_str(v: &Value) -> Option<String> {
41	    match v {
42	        Value::String(s) => Some(s.clone()),
43	        Value::Number(n) => Some(n.to_string()),
44	        Value::Bool(b) => Some(b.to_string()),
45	        _ => None,
46	    }
47	}
48
49	fn tool_arg(input: &Value) -> String {
50	    let obj = match input.as_object() {
51	        Some(o) => o,
52	        None => return String::new(),
53	    };
54	    for k in ARG_KEYS {
55	        if let Some(s) = obj.get(*k).and_then(scalar_str) {
56	            if !s.is_empty() {
57	                return s;
58	            }
59	        }
60	    }
61	    for v in obj.values() {
62	        if let Some(s) = scalar_str(v) {
63	            if !s.is_empty() {
64	                return s;
65	            }
66	        }
67	    }
68	    serde_json::to_string(input).unwrap_or_default()
69	}
70
71	fn rec(role: &str, text: Option<&str>, tool: Option<Value>, ts: Option<&str>) -> Value {
72	    let mut m = serde_json::Map::new();
73	    m.insert("role".into(), json!(role));
74	    if let Some(t) = text {
75	        m.insert("text".into(), json!(t));
76	    }
77	    if let Some(t) = tool {
78	        m.insert("tool".into(), t);
79	    }
80	    if let Some(t) = ts {
81	        if !t.is_empty() {
82	            m.insert("ts".into(), json!(t));
83	        }
84	    }
85	    Value::Object(m)
86	}
87
88	/// Read CC JSONL from `reader`, write digest NDJSON to `writer`. Pure over byte streams so it is
89	/// directly unit-testable without touching the filesystem.
90	fn extract<R: BufRead, W: Write>(reader: R, mut writer: W) -> io::Result<()> {
91	    for line in reader.lines() {
92	        let line = line?;
93	        let line = line.trim();
94	        if line.is_empty() {
95	            continue;
96	        }
97	        let o: Value = match serde_json::from_str(line) {
98	            Ok(v) => v,
99	            Err(_) => continue, // CC's log is well-formed; a stray line is not ours to emit
100	        };
101	        let typ = o.get("type").and_then(Value::as_str).unwrap_or("");
102	        let ts = o.get("timestamp").and_then(Value::as_str);
103	        let msg = match o.get("message").and_then(Value::as_object) {
104	            Some(m) => m,
105	            None => continue,
106	        };
107	        let role = msg.get("role").and_then(Value::as_str).unwrap_or("");
108	        let content = msg.get("content");
109
110	        if typ == "user" && role == "user" {
111	            // A plain prompt is a string. List-content user turns are tool_results -> skip those.
112	            match content {
113	                Some(Value::String(s)) if !s.is_empty() => {
114	                    writeln!(writer, "{}", rec("input", Some(s), None, ts))?;
115	                }
116	                Some(Value::Array(items)) => {
117	                    for it in items {
118	                        if it.get("type").and_then(Value::as_str) == Some("text") {
119	                            if let Some(t) = it.get("text").and_then(Value::as_str) {
120	                                if !t.is_empty() {
121	                                    writeln!(writer, "{}", rec("input", Some(t), None, ts))?;
122	                                }
123	                            }
124	                        }
125	                    }
126	                }
127	                _ => {}
128	            }
129	        } else if typ == "assistant" && role == "assistant" {
130	            if let Some(Value::Array(items)) = content {
131	                for it in items {
132	                    match it.get("type").and_then(Value::as_str) {
133	                        Some("text") => {
134	                            if let Some(t) = it.get("text").and_then(Value::as_str) {
135	                                if !t.is_empty() {
136	                                    writeln!(writer, "{}", rec("agent", Some(t), None, ts))?;
137	                                }
138	                            }
139	                        }
140	                        Some("tool_use") => {
141	                            let name = it.get("name").and_then(Value::as_str).unwrap_or("");
142	                            let arg = it.get("input").map(tool_arg).unwrap_or_default();
143	                            let tool = json!({"name": name, "arg": arg});
144	                            writeln!(writer, "{}", rec("tool", None, Some(tool), ts))?;
145	                        }
146	                        _ => {} // thinking, etc. -> skip
147	                    }
148	                }
149	            }
150	        }
151	        // other event types -> skip
152	    }
153	    writer.flush()
154	}
155
156	/// Locate `<session>.jsonl` under `root` (depth-1 guess first, then a bounded recursive sweep —
157	/// the cwd-slug subdir is CC-internal so the exact path is not known a priori).
158	fn locate(root: &Path, session: &str) -> Option<PathBuf> {
159	    let target = format!("{session}.jsonl");
160	    // Depth-1: root/<slug>/<session>.jsonl
161	    if let Ok(entries) = fs::read_dir(root) {
162	        for e in entries.flatten() {
163	            let p = e.path();
164	            if p.is_dir() {
165	                let cand = p.join(&target);
166	                if cand.is_file() {
167	                    return Some(cand);
168	                }
169	            }
170	        }
171	    }
172	    // Bounded recursive fallback.
173	    find_recursive(root, &target, 6)
174	}
175
176	fn find_recursive(dir: &Path, target: &str, depth: usize) -> Option<PathBuf> {
177	    if depth == 0 {
178	        return None;
179	    }
180	    let entries = fs::read_dir(dir).ok()?;
181	    for e in entries.flatten() {
182	        let p = e.path();
183	        if p.is_file() {
184	            if p.file_name().and_then(|n| n.to_str()) == Some(target) {
185	                return Some(p);
186	            }
187	        } else if p.is_dir() {
188	            if let Some(hit) = find_recursive(&p, target, depth - 1) {
189	                return Some(hit);
190	            }
191	        }
192	    }
193	    None
194	}
195
196	struct Args {
197	    session: Option<String>,
198	    source: Option<String>,
199	}
200
201	fn parse_args() -> Result<Args, String> {
202	    let mut session = None;
203	    let mut source = None;
204	    let mut it = std::env::args().skip(1);
205	    while let Some(a) = it.next() {
206	        match a.as_str() {
207	            "--session" => session = it.next(),
208	            "--in" => source = it.next(),
209	            "-h" | "--help" => {
210	                println!("claude-spt-digest --session <id> --in <projects-root-or-logfile>");
211	                std::process::exit(0);
212	            }
213	            other => return Err(format!("unknown arg: {other}")),
214	        }
215	    }
216	    Ok(Args { session, source })
217	}
218
219	/// Expand a leading `~/` (or bare `~`) to the user's home. spt-core resolves `~` in the `source`
220	/// template, but the extractor expands defensively too — it runs argv-direct (no shell), so a
221	/// literal `~` would otherwise become a bogus relative path.
222	fn expand_tilde(p: &str) -> PathBuf {
223	    if p == "~" || p.starts_with("~/") || p.starts_with("~\\") {
224	        if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
225	            let rest = p[1..].trim_start_matches(['/', '\\']);
226	            return Path::new(&home).join(rest);
227	        }
228	    }
229	    PathBuf::from(p)
230	}
231
232	fn run() -> Result<(), String> {
233	    let args = parse_args()?;
234	    let source = args.source.ok_or("missing required --in")?;
235	    let src = expand_tilde(&source);
236	    let src = src.as_path();
237
238	    // --in may be the projects ROOT (dir) or a direct log file (digest-proof --sample).
239	    let path: Option<PathBuf> = if src.is_file() {
240	        Some(src.to_path_buf())
241	    } else if src.is_dir() {
242	        let session = args.session.ok_or("--in is a directory but no --session to locate")?;
243	        locate(src, &session)
244	    } else {
245	        None
246	    };
247
248	    let path = match path {
249	        Some(p) => p,
250	        None => return Ok(()), // no log -> empty digest (not an error; spt-core reports the empty)
251	    };
252
253	    let file = File::open(&path).map_err(|e| format!("open {}: {e}", path.display()))?;
254	    let stdout = io::stdout();
255	    let writer = BufWriter::new(stdout.lock());
256	    extract(BufReader::new(file), writer).map_err(|e| e.to_string())
257	}
258
259	fn main() -> ExitCode {
260	    match run() {
261	        Ok(()) => ExitCode::SUCCESS,
262	        Err(e) => {
263	            eprintln!("claude-spt-digest: {e}");
264	            ExitCode::from(2)
265	        }
266	    }
267	}
268
269	// [unit->REQ-DIST-DIGEST-EXTRACTOR]
270	#[cfg(test)]
271	mod tests {
272	    use super::*;
273	    use serde_json::Value;
274
275	    fn run_extract(input: &str) -> Vec<Value> {
276	        let mut out = Vec::new();
277	        extract(input.as_bytes(), &mut out).unwrap();
278	        String::from_utf8(out)
279	            .unwrap()
280	            .lines()
281	            .filter(|l| !l.trim().is_empty())
282	            .map(|l| serde_json::from_str(l).expect("each output line is valid JSON"))
283	            .collect()
284	    }
285
286	    #[test]
287	    fn user_string_is_input() {
288	        let recs = run_extract(
289	            r#"{"type":"user","timestamp":"2026-06-15T10:00:00Z","message":{"role":"user","content":"hello"}}"#,
290	        );
291	        assert_eq!(recs.len(), 1);
292	        assert_eq!(recs[0]["role"], "input");
293	        assert_eq!(recs[0]["text"], "hello");
294	        assert_eq!(recs[0]["ts"], "2026-06-15T10:00:00Z");
295	    }
296
297	    #[test]
298	    fn assistant_text_is_agent_thinking_skipped() {
299	        let recs = run_extract(
300	            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","thinking":"x"},{"type":"text","text":"hi"}]}}"#,
301	        );
302	        assert_eq!(recs.len(), 1, "thinking dropped, text kept");
303	        assert_eq!(recs[0]["role"], "agent");
304	        assert_eq!(recs[0]["text"], "hi");
305	    }
306
307	    #[test]
308	    fn tool_use_maps_name_and_arg() {
309	        let recs = run_extract(
310	            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Write","input":{"file_path":"src/a.rs","content":"fn main(){}"}}]}}"#,
311	        );
312	        assert_eq!(recs.len(), 1);
313	        assert_eq!(recs[0]["role"], "tool");
314	        assert_eq!(recs[0]["tool"]["name"], "Write");
315	        assert_eq!(recs[0]["tool"]["arg"], "src/a.rs"); // file_path preferred over content
316	    }
317
318	    #[test]
319	    fn tool_arg_prefers_command_then_first_scalar() {
320	        let recs = run_extract(
321	            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{"command":"cargo build","description":"d"}}]}}"#,
322	        );
323	        assert_eq!(recs[0]["tool"]["arg"], "cargo build");
324	    }
325
326	    #[test]
327	    fn user_tool_result_is_skipped() {
328	        let recs = run_extract(
329	            r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"ok"}]}}"#,
330	        );
331	        assert!(recs.is_empty(), "tool_result has no contract role");
332	    }
333
334	    #[test]
335	    fn noise_types_and_bad_lines_skipped() {
336	        let input = concat!(
337	            r#"{"type":"attachment","attachment":{}}"#,
338	            "\n",
339	            r#"{"type":"last-prompt","lastPrompt":"x"}"#,
340	            "\n",
341	            "this is not json\n",
342	            "\n",
343	            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"only this"}]}}"#,
344	        );
345	        let recs = run_extract(input);
346	        assert_eq!(recs.len(), 1);
347	        assert_eq!(recs[0]["text"], "only this");
348	    }
349
350	    #[test]
351	    fn non_ascii_round_trips_utf8() {
352	        let recs = run_extract(
353	            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"done — built clean"}]}}"#,
354	        );
355	        assert_eq!(recs[0]["text"], "done — built clean");
356	    }
357
358	    #[test]
359	    fn tilde_expands_to_home() {
360	        std::env::set_var("HOME", "/home/test");
361	        assert_eq!(expand_tilde("~/.claude/projects"), Path::new("/home/test").join(".claude/projects"));
362	        assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path"));
363	        assert_eq!(expand_tilde("rel/path"), PathBuf::from("rel/path"));
364	    }
365
366	    #[test]
367	    fn every_record_satisfies_the_contract() {
368	        // role enum + field-presence invariants across a mixed stream.
369	        let input = concat!(
370	            r#"{"type":"user","message":{"role":"user","content":"q"}}"#,
371	            "\n",
372	            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"a"},{"type":"tool_use","name":"Read","input":{"file_path":"f"}}]}}"#,
373	        );
374	        for r in run_extract(input) {
375	            let role = r["role"].as_str().unwrap();
376	            assert!(matches!(role, "input" | "agent" | "tool"), "role enum: {role}");
377	            match role {
378	                "input" | "agent" => assert!(r.get("text").is_some() && r.get("tool").is_none()),
379	                "tool" => {
380	                    assert!(r.get("text").is_none());
381	                    assert!(r["tool"].get("name").is_some() && r["tool"].get("arg").is_some());
382	                }
383	                _ => unreachable!(),
384	            }
385	        }
386	    }
387	}
388

//! claude-spt-digest — the `[digest]` session-digest extractor for the claude-spt adapter (ADR-0019).
//!
//! 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).
//!
//! 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)
}

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

    // --in may be the projects ROOT (dir) or a direct log file (digest-proof --sample).
    let path: Option<PathBuf> = if src.is_file() {
        Some(src.to_path_buf())
    } else if src.is_dir() {
        let session = args.session.ok_or("--in is a directory but no --session to locate")?;
        locate(src, &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"));
    }

    #[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!(),
            }
        }
    }
}


Now I have everything. The current extractor resolves `--in` literally — it does **not** honor `CLAUDE_CONFIG_DIR`, so under a ccs session it would miss the relocated transcripts. That's the real impl gap (and the clean `impl` evidence for REQ-CCS-PROFILES — solves the trace gotcha: a `.toml` leaf won't register as impl, but this Rust resolver does).

**Phase D design (validated against owl):**
- `[profiles.ccs]` overlay: `session.self.command` = bare `ccs` (drop-in for `claude`) + observability `strings.adapter_label` leaf (mirror `:deep`). NO digest leaf — env-awareness belongs in the binary, not a static path.
- `claude-spt-digest`: prefer `$CLAUDE_CONFIG_DIR/projects` on the dir-locate branch (mirror owl's `claude_projects_root()`), keep `--sample` file path untouched.
- registration-int: assert `claude-spt:ccs` resolves + adapter_label overlay observable.

Let me check the gate runner + existing docs/PARITY.md before editing.


Bash

cd "C:\Users\decid\Documents\projects\spt-claude-code" && echo "===run-gates===" && cat ci/run-gates.sh 2>/dev/null | head -60

Read gate runner


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

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

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

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

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

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

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

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

gate "docs-drift"


1	# Parity audit — claude-spt vs the legacy owl surface
2
3	> The reconciliation record: the adapter's operative surface measured against the **LOCKED
4	> parity-trim** in `SCOPE.md` (KEEP / ADD / DROP / TRANSFORM). This is the **"proven parity"**
5	> gate the `sptc`→`spt` succession flip depends on (see `docs/RELEASE-RUNBOOK.md` +
6	> `docs/KNOWN-HAZARDS.md`): the name flip happens only once this audit shows no unresolved
7	> divergence AND legacy owl is retired. Audited 2026-06-15 against published spt v0.7.2.
8	>
9	> Public-surface-only binds (AGENTS.md): a capability missing from the published `spt` surface is
10	> a **finding**, never a reason to reach into spt-core source.
11
12	<!-- [doc->REQ-PARITY-AUDIT] -->
13
14	## Disposition matrix
15
16	| Surface | SCOPE bucket | Adapter today | Status |
17	|---|---|---|---|
18	| `/live` | KEEP | operative — `skills/live.md` (`:live` profile + Monitor relay) | ✅ parity |
19	| `/commune` | KEEP | operative — `skills/commune.md` (file-drop) | ✅ parity |
20	| `/ready` | KEEP | operative — `skills/ready.md` | ✅ parity |
21	| `/send` | KEEP | operative — `skills/send.md` | ✅ parity |
22	| `/list-agents` | KEEP (→ `spt endpoint list`) | operative — `skills/list-agents.md` | ✅ parity |
23	| `/signoff` | KEEP (harness-hosted v1) | operative — `skills/signoff.md` | ✅ parity |
24	| `/force-stop` | KEEP (topology-aware) | operative — `skills/force-stop.md` | ✅ parity |
25	| `/spt:setup` | ADD | operative — `skills/setup.md` | ✅ parity |
26	| `/spt:version` | ADD | operative — `skills/version.md` | ✅ parity |
27	| profiles + strings + hints | ADD | wired (`:deep`, `:live`, `[strings]`, `[[hints]]`) | ✅ parity |
28	| **subnet skills (create/join/show-code)** | ADD (LOCKED) | **absent** | 🔨 BUILD (REQ-SKILL-SUBNET) |
29	| **ccs profiles (`claude-spt:glm` …)** | ADD (LOCKED) | **absent** | 🔨 BUILD (REQ-CCS-PROFILES) |
30	| `/whoami` skill | **DROP** (core `spt whoami` stays → M12 `endpoint list` alias) | operative (carry-over) | ⛔ REMOVE |
31	| `/new-alarm` | accepted gap | dead stub, no manifest entry | ⏸ DEFER + remove stub |
32	| `/fork` | DROP | absent | ✅ confirmed absent |
33	| `/amend-signoff` | DROP | absent | ✅ confirmed absent |
34	| `/revive` | DROP (daemon owns liveness) | absent | ✅ confirmed absent |
35	| `/clear-psyche` | DROP (= delete+recreate endpoint) | absent | ✅ confirmed absent |
36
37	TRANSFORM bucket (already realised, not skills): capsule→spt-hosted+`cc` (`[session.self]` +
38	`shortcut_basename="cc"`); echo-commune→`[digest]` extractor (`claude-spt-digest`); psyche-sync→
39	`/spt:setup`; doctor→setup-verify; working-perches→`api worker-start/stop` hooks. ✅
40
41	## Open divergences and dispositions
42
43	### subnet skills — LOCKED-ADD gap → BUILD
44	The published surface carries the verbs (`spt subnet {create,join,show-code,status,…}`), and v1
45	topology scope is **mandatory** (operator vetoed harness-hosted-only — cross-subnet/PTY proof is
46	spt-core's central value prop, SCOPE). So this is a real gap to fill, not defer. → `REQ-SKILL-SUBNET`.
47
48	### ccs profiles — LOCKED-ADD gap → BUILD
49	SCOPE §ccs (LOCKED): ccs ships as **profile templates** under the adapter (`claude-spt:glm`, …)
50	that retarget the spawn/psyche/echo command templates through `ccs <profile>`, with a per-profile
51	`~/.ccs` log dir. Templates only — the user supplies their own ccs config/keys. → `REQ-CCS-PROFILES`.
52
53	### `/whoami` skill — DROP (honoring LOCKED)
54	SCOPE DROPs the whoami **skill**; core `spt whoami` stays in the hot path (and becomes the
55	`endpoint list` alias in M12). The adapter currently still ships a whoami skill (a carry-over from
56	an early slice). Operator confirmed the drop 2026-06-15. Action: remove `[strings.skills].whoami` +
57	the file body + the plugin stub; redirect the "who am i" hint to core `spt whoami`. No REQ rides
58	whoami evidence (the file-backed-string + UPS-injection int examples repoint to `skills.ready`;
59	the hook-parser unit examples repoint to a surviving skill name). Core `spt whoami` calls in the
60	hooks (`session-start.sh`, `_common.sh`) are the CLI, not the skill — they STAY.
61
62	### `/new-alarm` — ACCEPTED GAP (deferred-pending-arrangement-system)
63	**Not missing — deliberately deferred.** doyle triage (2026-06-15): spt-core carries only the
64	alarm *event shape* (spt-proto) + relay handling (psyrelay.rs) + a test fixture; the durable
65	"fire-at-target-time" timer still lives in legacy owl in-memory, and an in-daemon durable scheduler
66	is a deliberate deferral (DEFERRED.md:43, ADR-0018 Q4/V3 — deferred because the daemon had no
67	one-shot consumer; building pre-consumer = untested dead code). The **operator has a superseding
68	"arrangement" system** in mind that would replace the alarm port. Disposition: **do not build an
69	adapter-side stopgap timer** (throwaway vs the coming model); **no REQ minted**; **remove the dead
70	`/sptc:new-alarm` stub** from the published surface (deferred ≠ shipping a no-op skill). doyle loops
71	me when the arrangement design surfaces.
72
73	## Parity verdict
74	**Not yet proven.** Two LOCKED-ADD surfaces (subnet skills, ccs profiles) are unbuilt and one DROP
75	(whoami skill) is unhonored. Parity is proven — and the succession flip unlocked — once those three
76	close (this slice) with the rest already ✅. `/new-alarm` is an accepted, documented gap and does
77	**not** block the parity verdict.
78

# Parity audit — claude-spt vs the legacy owl surface

> The reconciliation record: the adapter's operative surface measured against the **LOCKED
> parity-trim** in `SCOPE.md` (KEEP / ADD / DROP / TRANSFORM). This is the **"proven parity"**
> gate the `sptc`→`spt` succession flip depends on (see `docs/RELEASE-RUNBOOK.md` +
> `docs/KNOWN-HAZARDS.md`): the name flip happens only once this audit shows no unresolved
> divergence AND legacy owl is retired. Audited 2026-06-15 against published spt v0.7.2.
>
> Public-surface-only binds (AGENTS.md): a capability missing from the published `spt` surface is
> a **finding**, never a reason to reach into spt-core source.

<!-- [doc->REQ-PARITY-AUDIT] -->

## Disposition matrix

| Surface | SCOPE bucket | Adapter today | Status |
|---|---|---|---|
| `/live` | KEEP | operative — `skills/live.md` (`:live` profile + Monitor relay) | ✅ parity |
| `/commune` | KEEP | operative — `skills/commune.md` (file-drop) | ✅ parity |
| `/ready` | KEEP | operative — `skills/ready.md` | ✅ parity |
| `/send` | KEEP | operative — `skills/send.md` | ✅ parity |
| `/list-agents` | KEEP (→ `spt endpoint list`) | operative — `skills/list-agents.md` | ✅ parity |
| `/signoff` | KEEP (harness-hosted v1) | operative — `skills/signoff.md` | ✅ parity |
| `/force-stop` | KEEP (topology-aware) | operative — `skills/force-stop.md` | ✅ parity |
| `/spt:setup` | ADD | operative — `skills/setup.md` | ✅ parity |
| `/spt:version` | ADD | operative — `skills/version.md` | ✅ parity |
| profiles + strings + hints | ADD | wired (`:deep`, `:live`, `[strings]`, `[[hints]]`) | ✅ parity |
| **subnet skills (create/join/show-code)** | ADD (LOCKED) | **absent** | 🔨 BUILD (REQ-SKILL-SUBNET) |
| **ccs profiles (`claude-spt:glm` …)** | ADD (LOCKED) | **absent** | 🔨 BUILD (REQ-CCS-PROFILES) |
| `/whoami` skill | **DROP** (core `spt whoami` stays → M12 `endpoint list` alias) | operative (carry-over) | ⛔ REMOVE |
| `/new-alarm` | accepted gap | dead stub, no manifest entry | ⏸ DEFER + remove stub |
| `/fork` | DROP | absent | ✅ confirmed absent |
| `/amend-signoff` | DROP | absent | ✅ confirmed absent |
| `/revive` | DROP (daemon owns liveness) | absent | ✅ confirmed absent |
| `/clear-psyche` | DROP (= delete+recreate endpoint) | absent | ✅ confirmed absent |

TRANSFORM bucket (already realised, not skills): capsule→spt-hosted+`cc` (`[session.self]` +
`shortcut_basename="cc"`); echo-commune→`[digest]` extractor (`claude-spt-digest`); psyche-sync→
`/spt:setup`; doctor→setup-verify; working-perches→`api worker-start/stop` hooks. ✅

## Open divergences and dispositions

### subnet skills — LOCKED-ADD gap → BUILD
The published surface carries the verbs (`spt subnet {create,join,show-code,status,…}`), and v1
topology scope is **mandatory** (operator vetoed harness-hosted-only — cross-subnet/PTY proof is
spt-core's central value prop, SCOPE). So this is a real gap to fill, not defer. → `REQ-SKILL-SUBNET`.

### ccs profiles — LOCKED-ADD gap → BUILD
SCOPE §ccs (LOCKED): ccs ships as **profile templates** under the adapter (`claude-spt:glm`, …)
that retarget the spawn/psyche/echo command templates through `ccs <profile>`, with a per-profile
`~/.ccs` log dir. Templates only — the user supplies their own ccs config/keys. → `REQ-CCS-PROFILES`.

### `/whoami` skill — DROP (honoring LOCKED)
SCOPE DROPs the whoami **skill**; core `spt whoami` stays in the hot path (and becomes the
`endpoint list` alias in M12). The adapter currently still ships a whoami skill (a carry-over from
an early slice). Operator confirmed the drop 2026-06-15. Action: remove `[strings.skills].whoami` +
the file body + the plugin stub; redirect the "who am i" hint to core `spt whoami`. No REQ rides
whoami evidence (the file-backed-string + UPS-injection int examples repoint to `skills.ready`;
the hook-parser unit examples repoint to a surviving skill name). Core `spt whoami` calls in the
hooks (`session-start.sh`, `_common.sh`) are the CLI, not the skill — they STAY.

### `/new-alarm` — ACCEPTED GAP (deferred-pending-arrangement-system)
**Not missing — deliberately deferred.** doyle triage (2026-06-15): spt-core carries only the
alarm *event shape* (spt-proto) + relay handling (psyrelay.rs) + a test fixture; the durable
"fire-at-target-time" timer still lives in legacy owl in-memory, and an in-daemon durable scheduler
is a deliberate deferral (DEFERRED.md:43, ADR-0018 Q4/V3 — deferred because the daemon had no
one-shot consumer; building pre-consumer = untested dead code). The **operator has a superseding
"arrangement" system** in mind that would replace the alarm port. Disposition: **do not build an
adapter-side stopgap timer** (throwaway vs the coming model); **no REQ minted**; **remove the dead
`/sptc:new-alarm` stub** from the published surface (deferred ≠ shipping a no-op skill). doyle loops
me when the arrangement design surfaces.

## Parity verdict
**Not yet proven.** Two LOCKED-ADD surfaces (subnet skills, ccs profiles) are unbuilt and one DROP
(whoami skill) is unhonored. Parity is proven — and the succession flip unlocked — once those three
close (this slice) with the rest already ✅. `/new-alarm` is an accepted, documented gap and does
**not** block the parity verdict.


Have full picture. Also: PARITY.md rows for whoami/subnet are stale (B/C done but doc not flipped) — I'll bring them current in this commit too. Execute Phase D.

**1. Activate REQ-CCS-PROFILES.**


Edit

title = "The adapter ships ccs profile template(s) (claude-spt:glm …) — LOCKED-ADD overlays that retarget the spawn/psyche/echo command templates through `ccs <profile>` with a per-profile ~/.ccs log dir; templates only (user supplies their own ccs config/keys)"
required_stages = []  # activate [doc,impl] when Phase D starts. impl: [profiles.<glm>] overlay(s) in claude-spt.toml. int: registration-int profile-resolves assertion (mirrors :deep/:live).

title = "The adapter ships ccs profile template(s) (claude-spt:ccs) — LOCKED-ADD overlay that retargets the spawn command template through `ccs` (a drop-in for the `claude` binary) with ccs's relocated CLAUDE_CONFIG_DIR transcript root honored by the digest extractor; templates only (user supplies their own ccs config/keys)"
# impl/unit = the CLAUDE_CONFIG_DIR-aware projects-root resolver in claude-spt-digest (a .toml
# profile leaf does NOT register as impl evidence; the real impl is the env-aware extractor, the
# owl-validated seam — owlery::claude_projects_root). doc = manifest [profiles.ccs] overlay + PARITY.
# int = registration-int profile-resolves assertion (mirrors :deep/:live).
required_stages = ["doc", "impl", "unit", "int"]


<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>

Error: File has not been read yet. Read it first before writing to it.


163	[[requirements]]
164	id = "REQ-SKILL-SUBNET"
165	title = "The adapter ships subnet membership skill(s) (create/join/show-code) wrapping the published `spt subnet <verb>` surface — the LOCKED-ADD cross-subnet entrypoint (v1 topology is mandatory, SCOPE)"
166	required_stages = ["doc", "int"]  # doc: adapter/strings/skills/subnet.md (one /sptc:subnet skill covering status/create/show-code/join — mirrors the single `spt subnet` verb-family CLI). int: ci/manifest/registration-int.sh file-backed-resolve assertion (skills.subnet -> body) on live spt — the wiring proof. No separate `impl`/`unit` stage: a file-backed skill has no unit-testable code of its own (the manifest [strings.skills].subnet pointer + plugin stub are config, and the int proves they resolve end-to-end through the live registry — same model as the commune/force-stop skills, which carry no per-skill impl). (No `spt how-to subnet` topic exists — minor published-guidance gap noted in docs/SPT-CORE-FINDINGS.md; the skill wraps `spt subnet --help` as canonical.)
167
168	[[requirements]]
169	id = "REQ-CCS-PROFILES"
170	title = "The adapter ships ccs profile template(s) (claude-spt:glm …) — LOCKED-ADD overlays that retarget the spawn/psyche/echo command templates through `ccs <profile>` with a per-profile ~/.ccs log dir; templates only (user supplies their own ccs config/keys)"
171	required_stages = []  # activate [doc,impl] when Phase D starts. impl: [profiles.<glm>] overlay(s) in claude-spt.toml. int: registration-int profile-resolves assertion (mirrors :deep/:live).
172
173	[[requirements]]
174	id = "REQ-SKILL-VERSION"

[[requirements]]
id = "REQ-SKILL-SUBNET"
title = "The adapter ships subnet membership skill(s) (create/join/show-code) wrapping the published `spt subnet <verb>` surface — the LOCKED-ADD cross-subnet entrypoint (v1 topology is mandatory, SCOPE)"
required_stages = ["doc", "int"]  # doc: adapter/strings/skills/subnet.md (one /sptc:subnet skill covering status/create/show-code/join — mirrors the single `spt subnet` verb-family CLI). int: ci/manifest/registration-int.sh file-backed-resolve assertion (skills.subnet -> body) on live spt — the wiring proof. No separate `impl`/`unit` stage: a file-backed skill has no unit-testable code of its own (the manifest [strings.skills].subnet pointer + plugin stub are config, and the int proves they resolve end-to-end through the live registry — same model as the commune/force-stop skills, which carry no per-skill impl). (No `spt how-to subnet` topic exists — minor published-guidance gap noted in docs/SPT-CORE-FINDINGS.md; the skill wraps `spt subnet --help` as canonical.)

[[requirements]]
id = "REQ-CCS-PROFILES"
title = "The adapter ships ccs profile template(s) (claude-spt:glm …) — LOCKED-ADD overlays that retarget the spawn/psyche/echo command templates through `ccs <profile>` with a per-profile ~/.ccs log dir; templates only (user supplies their own ccs config/keys)"
required_stages = []  # activate [doc,impl] when Phase D starts. impl: [profiles.<glm>] overlay(s) in claude-spt.toml. int: registration-int profile-resolves assertion (mirrors :deep/:live).

[[requirements]]
id = "REQ-SKILL-VERSION"


title = "The adapter ships ccs profile template(s) (claude-spt:glm …) — LOCKED-ADD overlays that retarget the spawn/psyche/echo command templates through `ccs <profile>` with a per-profile ~/.ccs log dir; templates only (user supplies their own ccs config/keys)"
required_stages = []  # activate [doc,impl] when Phase D starts. impl: [profiles.<glm>] overlay(s) in claude-spt.toml. int: registration-int profile-resolves assertion (mirrors :deep/:live).

title = "The adapter ships a ccs profile template (claude-spt:ccs) — LOCKED-ADD overlay that retargets the spawn command template through `ccs` (a drop-in for the `claude` binary) and honors ccs's relocated CLAUDE_CONFIG_DIR transcript root in the digest extractor; templates only (user supplies their own ccs config/keys)"
# Scoped to the default-profile shape the operator actually runs: `ccs` (bare), the account set via
# `ccs auth default <name>`, NOT glm/kimi (those were SCOPE *examples*; this profile IS the example +
# the operator's own SPT use). Validated against the known-good sister project claude_skill_owl:
# (1) ccs is a drop-in for the `claude` binary, same argv (live/wrapper/claude.rs Tier-2 latch);
# (2) ccs relocates Claude Code's state tree via CLAUDE_CONFIG_DIR, resolved IN-BINARY by
# owlery::claude_projects_root ($CLAUDE_CONFIG_DIR/projects || $HOME/.claude/projects).
# impl/unit = the CLAUDE_CONFIG_DIR-aware projects-root resolver in claude-spt-digest (a .toml
# profile leaf does NOT register as impl evidence; the real impl is the env-aware extractor).
# doc = manifest [profiles.ccs] overlay + docs/PARITY.md. int = registration-int profile-resolves
# assertion (claude-spt:ccs resolves + adapter_label overlay observable; mirrors :deep/:live).
required_stages = ["doc", "impl", "unit", "int"]


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 ships ccs profile template(s) (claude-spt:glm …) — LOCKED-ADD overlays that retarget the spawn/psyche/echo command templates through `ccs <profile>` with a per-profile ~/.ccs log dir; templates only (user supplies their own ccs config/keys)"
required_stages = []  # activate [doc,impl] when Phase D starts. impl: [profiles.<glm>] overlay(s) in claude-spt.toml. int: registration-int profile-resolves assertion (mirrors :deep/:live).

title = "The adapter ships a ccs profile template (claude-spt:ccs) — LOCKED-ADD overlay that retargets the spawn command template through `ccs` (a drop-in for the `claude` binary) and honors ccs's relocated CLAUDE_CONFIG_DIR transcript root in the digest extractor; templates only (user supplies their own ccs config/keys)"
# Scoped to the default-profile shape the operator actually runs: `ccs` (bare), the account set via
# `ccs auth default <name>`, NOT glm/kimi (those were SCOPE *examples*; this profile IS the example +
# the operator's own SPT use). Validated against the known-good sister project claude_skill_owl:
# (1) ccs is a drop-in for the `claude` binary, same argv (live/wrapper/claude.rs Tier-2 latch);
# (2) ccs relocates Claude Code's state tree via CLAUDE_CONFIG_DIR, resolved IN-BINARY by
# owlery::claude_projects_root ($CLAUDE_CONFIG_DIR/projects || $HOME/.claude/projects).
# impl/unit = the CLAUDE_CONFIG_DIR-aware projects-root resolver in claude-spt-digest (a .toml
# profile leaf does NOT register as impl evidence; the real impl is the env-aware extractor).
# doc = manifest [profiles.ccs] overlay + docs/PARITY.md. int = registration-int profile-resolves
# assertion (claude-spt:ccs resolves + adapter_label overlay observable; mirrors :deep/:live).
required_stages = ["doc", "impl", "unit", "int"]

-title = "The adapter ships ccs profile template(s) (claude-spt:glm …) — LOCKED-ADD overlays that retarget the spawn/psyche/echo command templates through `ccs <profile>` with a per-profile ~/.ccs log dir; templates only (user supplies their own ccs config/keys)"

-required_stages = []  # activate [doc,impl] when Phase D starts. impl: [profiles.<glm>] overlay(s) in claude-spt.toml. int: registration-int profile-resolves assertion (mirrors :deep/:live).

+title = "The adapter ships a ccs profile template (claude-spt:ccs) — LOCKED-ADD overlay that retargets the spawn command template through `ccs` (a drop-in for the `claude` binary) and honors ccs's relocated CLAUDE_CONFIG_DIR transcript root in the digest extractor; templates only (user supplies their own ccs config/keys)"

+# Scoped to the default-profile shape the operator actually runs: `ccs` (bare), the account set via

+# `ccs auth default <name>`, NOT glm/kimi (those were SCOPE *examples*; this profile IS the example +

+# the operator's own SPT use). Validated against the known-good sister project claude_skill_owl:

+# (1) ccs is a drop-in for the `claude` binary, same argv (live/wrapper/claude.rs Tier-2 latch);

+# (2) ccs relocates Claude Code's state tree via CLAUDE_CONFIG_DIR, resolved IN-BINARY by

+# owlery::claude_projects_root ($CLAUDE_CONFIG_DIR/projects || $HOME/.claude/projects).

+# impl/unit = the CLAUDE_CONFIG_DIR-aware projects-root resolver in claude-spt-digest (a .toml

+# profile leaf does NOT register as impl evidence; the real impl is the env-aware extractor).

+# doc = manifest [profiles.ccs] overlay + docs/PARITY.md. int = registration-int profile-resolves

+# assertion (claude-spt:ccs resolves + adapter_label overlay observable; mirrors :deep/:live).

+required_stages = ["doc", "impl", "unit", "int"]


**2. Add `[profiles.ccs]` overlay** to the manifest (after `[profiles.live...]`, before `[strings]`).


# ── [strings] ──────────────────────────────────────────────────────────────────────────────
# Opaque adapter data (spt-core never executes it), dot-path-readable via `spt adapter get-string`.

# `ccs` — the LOCKED-ADD ccs profile template. Selected as `claude-spt:ccs`. ccs is the model/billing
# multiplexer (kaitranntt/ccs): structurally Claude Code, only the launch binary + backend differ —
# exactly the profile seam (SCOPE §ccs, LOCKED). This overlay leaf-replaces ONE leaf: the spawn
# command. `ccs` (bare) is a DROP-IN for `claude` and launches the user's ccs *default* account
# (set via `ccs auth default <name>`); SPT_ENDPOINT_ID rides the inherited env through the wrapper
# exactly as for bare `claude`. Validated against the known-good sister project claude_skill_owl,
# which latches `cli_binary = "ccs"` as a same-argv `claude` substitute (live/wrapper/claude.rs).
# This profile IS the worked example of adapter-profile authoring + the operator's own SPT-ecosystem
# hook; a user wanting a *specific* ccs profile clones it with `command = "ccs glm"` etc.
#
# NO `[profiles.ccs.digest]` leaf by design: ccs relocates Claude Code's transcript tree via the
# CLAUDE_CONFIG_DIR env var (e.g. ~/.ccs/instances/<account>/.claude), whose value is set by ccs at
# runtime per-account and is NOT expressible as a static catalog path. The "per-profile ~/.ccs log
# dir" SCOPE requirement is therefore honored IN THE EXTRACTOR (claude-spt-digest prefers
# $CLAUDE_CONFIG_DIR/projects over the base ~/.claude/projects on its dir-locate branch — the
# owl-validated owlery::claude_projects_root pattern), so the base [digest] config serves both base
# and ccs sessions transparently. That env-aware resolver is REQ-CCS-PROFILES's impl evidence.
# [doc->REQ-CCS-PROFILES]
[profiles.ccs.session.self]
command = "ccs"

# Observability leaf (mirrors :deep): makes the overlay visible via `spt adapter get-string
# claude-spt:ccs adapter_label` (differs from base) — the registration-int resolve proof.
[profiles.ccs.strings]
adapter_label = "Claude Code (spt, ccs)"

# ── [strings] ──────────────────────────────────────────────────────────────────────────────
# Opaque adapter data (spt-core never executes it), dot-path-readable via `spt adapter get-string`.


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

# `ccs` — the LOCKED-ADD ccs profile template. Selected as `claude-spt:ccs`. ccs is the model/billing
# multiplexer (kaitranntt/ccs): structurally Claude Code, only the launch binary + backend differ —
# exactly the profile seam (SCOPE §ccs, LOCKED). This overlay leaf-replaces ONE leaf: the spawn
# command. `ccs` (bare) is a DROP-IN for `claude` and launches the user's ccs *default* account
# (set via `ccs auth default <name>`); SPT_ENDPOINT_ID rides the inherited env through the wrapper
# exactly as for bare `claude`. Validated against the known-good sister project claude_skill_owl,
# which latches `cli_binary = "ccs"` as a same-argv `claude` substitute (live/wrapper/claude.rs).
# This profile IS the worked example of adapter-profile authoring + the operator's own SPT-ecosystem
# hook; a user wanting a *specific* ccs profile clones it with `command = "ccs glm"` etc.
#
# NO `[profiles.ccs.digest]` leaf by design: ccs relocates Claude Code's transcript tree via the
# CLAUDE_CONFIG_DIR env var (e.g. ~/.ccs/instances/<account>/.claude), whose value is set by ccs at
# runtime per-account and is NOT expressible as a static catalog path. The "per-profile ~/.ccs log
# dir" SCOPE requirement is therefore honored IN THE EXTRACTOR (claude-spt-digest prefers
# $CLAUDE_CONFIG_DIR/projects over the base ~/.claude/projects on its dir-locate branch — the
# owl-validated owlery::claude_projects_root pattern), so the base [digest] config serves both base
# and ccs sessions transparently. That env-aware resolver is REQ-CCS-PROFILES's impl evidence.
# [doc->REQ-CCS-PROFILES]
[profiles.ccs.session.self]
command = "ccs"

# Observability leaf (mirrors :deep): makes the overlay visible via `spt adapter get-string
# claude-spt:ccs adapter_label` (differs from base) — the registration-int resolve proof.
[profiles.ccs.strings]
adapter_label = "Claude Code (spt, ccs)"

# ── [strings] ──────────────────────────────────────────────────────────────────────────────
# Opaque adapter data (spt-core never executes it), dot-path-readable via `spt adapter get-string`.

keys = ["id", "session_id", "psyche_dir", "psyche_prompt"]

+# `ccs` — the LOCKED-ADD ccs profile template. Selected as `claude-spt:ccs`. ccs is the model/billing

+# multiplexer (kaitranntt/ccs): structurally Claude Code, only the launch binary + backend differ —

+# exactly the profile seam (SCOPE §ccs, LOCKED). This overlay leaf-replaces ONE leaf: the spawn

+# command. `ccs` (bare) is a DROP-IN for `claude` and launches the user's ccs *default* account

+# (set via `ccs auth default <name>`); SPT_ENDPOINT_ID rides the inherited env through the wrapper

+# exactly as for bare `claude`. Validated against the known-good sister project claude_skill_owl,

+# which latches `cli_binary = "ccs"` as a same-argv `claude` substitute (live/wrapper/claude.rs).

+# This profile IS the worked example of adapter-profile authoring + the operator's own SPT-ecosystem

+# hook; a user wanting a *specific* ccs profile clones it with `command = "ccs glm"` etc.

+# NO `[profiles.ccs.digest]` leaf by design: ccs relocates Claude Code's transcript tree via the

+# CLAUDE_CONFIG_DIR env var (e.g. ~/.ccs/instances/<account>/.claude), whose value is set by ccs at

+# runtime per-account and is NOT expressible as a static catalog path. The "per-profile ~/.ccs log

+# dir" SCOPE requirement is therefore honored IN THE EXTRACTOR (claude-spt-digest prefers

+# $CLAUDE_CONFIG_DIR/projects over the base ~/.claude/projects on its dir-locate branch — the

+# owl-validated owlery::claude_projects_root pattern), so the base [digest] config serves both base

+# and ccs sessions transparently. That env-aware resolver is REQ-CCS-PROFILES's impl evidence.

+# Observability leaf (mirrors :deep): makes the overlay visible via `spt adapter get-string

+# claude-spt:ccs adapter_label` (differs from base) — the registration-int resolve proof.

+adapter_label = "Claude Code (spt, ccs)"

# Seed only this slice — the file-backed skill-instruction BODIES (UPS-injection source) land in


**3. Make `claude-spt-digest` CLAUDE_CONFIG_DIR-aware** (impl + unit).


//! 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).

//! 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.


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

//! 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.

+//! 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}


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

    // --in may be the projects ROOT (dir) or a direct log file (digest-proof --sample).
    let path: Option<PathBuf> = if src.is_file() {
        Some(src.to_path_buf())
    } else if src.is_dir() {
        let session = args.session.ok_or("--in is a directory but no --session to locate")?;
        locate(src, &session)
    } else {
        None
    };

/// 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
        }
    };


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

    // --in may be the projects ROOT (dir) or a direct log file (digest-proof --sample).
    let path: Option<PathBuf> = if src.is_file() {
        Some(src.to_path_buf())
    } else if src.is_dir() {
        let session = args.session.ok_or("--in is a directory but no --session to locate")?;
        locate(src, &session)
    } else {
        None
    };

/// 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
        }
    };

+/// 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> {

+        Some(c) if !c.is_empty() => Some(PathBuf::from(c).join("projects")),

-    // --in may be the projects ROOT (dir) or a direct log file (digest-proof --sample).

+    // --in may be a direct log FILE (digest-proof --sample) -> read it as-is, no env override.

-        let session = args.session.ok_or("--in is a directory but no --session to locate")?;

+        // 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);

+            let session = args.session.ok_or("--in is a directory but no --session to locate")?;


Now the unit tests:


    #[test]
    fn every_record_satisfies_the_contract() {

    // [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() {


    // [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() {

+    // [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).

+            ccs_projects_root_from(Some("/tmp/ccs-acct/.claude")),

+            Some(Path::new("/tmp/ccs-acct/.claude").join("projects"))

+        assert_eq!(ccs_projects_root_from(None), None);

+        // Empty env var must NOT shadow the --in fallback (owl: `!cfg.is_empty()` guard).

+        assert_eq!(ccs_projects_root_from(Some("")), None);

// role enum + field-presence invariants across a mixed stream.


**4. registration-int — ccs profile assertions** (int). Add after the `:live` profile resolve + after the `:deep` observability check.


case "$list" in *claude-spt:live*) ok "shipped profile resolves: claude-spt:live (LiveAgent overlay)" ;; *) bad "live profile not resolved" ;; esac

case "$list" in *claude-spt:live*) ok "shipped profile resolves: claude-spt:live (LiveAgent overlay)" ;; *) bad "live profile not resolved" ;; esac
# The ccs overlay (:ccs leaf-replaces [session.self].command -> `ccs`, a drop-in for `claude`) —
# the LOCKED-ADD ccs profile template (REQ-CCS-PROFILES; validated vs sister project claude_skill_owl).
case "$list" in *claude-spt:ccs*) ok "shipped profile resolves: claude-spt:ccs (ccs overlay)" ;; *) bad "ccs profile not resolved" ;; esac