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

    args = ap.parse_args()

    # The digest contract is UTF-8 NDJSON. Python defaults stdout to the locale encoding (cp1252 on
    # Windows), which mangles non-ASCII (em-dashes, smart quotes) into invalid bytes spt-core can't
    # decode. Pin UTF-8 so the extractor is byte-identical on the Win+Linux fleet. [impl->REQ-DIST-DIGEST-EXTRACTOR]
    try:
        sys.stdout.reconfigure(encoding="utf-8", newline="\n")
    except AttributeError:
        pass  # pre-3.7; fleet runs 3.7+

    src = os.path.expanduser(args.source)

#!/usr/bin/env python3
# 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. A record's 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>` instead 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]
import argparse
import glob
import json
import os
import sys

# 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 whole input.
ARG_KEYS = (
    "file_path", "path", "notebook_path", "command", "pattern", "query",
    "url", "prompt", "old_string", "description", "subagent_type",
)

def tool_arg(inp):
    if not isinstance(inp, dict):
        return ""
    for k in ARG_KEYS:
        v = inp.get(k)
        if isinstance(v, (str, int, float)) and str(v) != "":
            return str(v)
    for v in inp.values():
        if isinstance(v, (str, int, float)) and str(v) != "":
            return str(v)
    return json.dumps(inp, separators=(",", ":"), ensure_ascii=False)

def locate(source, session):
    # Direct depth-1 guess (<source>/<slug>/<session>.jsonl) before a recursive sweep.
    direct = glob.glob(os.path.join(source, "*", session + ".jsonl"))
    if direct:
        return direct[0]
    hits = glob.glob(os.path.join(source, "**", session + ".jsonl"), recursive=True)
    return hits[0] if hits else None

def rec(role, text=None, tool=None, ts=None):
    r = {"role": role}
    if text is not None:
        r["text"] = text
    if tool is not None:
        r["tool"] = tool
    if ts:
        r["ts"] = ts
    return r

def records(path):
    with open(path, encoding="utf-8") as fh:
        for line in fh:
            line = line.strip()
            if not line:
                continue
            try:
                o = json.loads(line)
            except json.JSONDecodeError:
                continue  # CC's log is well-formed; a stray line is not ours to emit
            if not isinstance(o, dict):
                continue
            typ = o.get("type")
            msg = o.get("message")
            ts = o.get("timestamp")
            if not isinstance(msg, dict):
                continue
            role = msg.get("role")
            cont = msg.get("content")
            if typ == "user" and role == "user":
                # A plain prompt is a string. List-content user turns are tool_results -> skip those.
                if isinstance(cont, str) and cont:
                    yield rec("input", text=cont, ts=ts)
                elif isinstance(cont, list):
                    for it in cont:
                        if isinstance(it, dict) and it.get("type") == "text" and it.get("text"):
                            yield rec("input", text=it["text"], ts=ts)
            elif typ == "assistant" and role == "assistant" and isinstance(cont, list):
                for it in cont:
                    if not isinstance(it, dict):
                        continue
                    t = it.get("type")
                    if t == "text" and it.get("text"):
                        yield rec("agent", text=it["text"], ts=ts)
                    elif t == "tool_use":
                        yield rec("tool", tool={"name": it.get("name", ""), "arg": tool_arg(it.get("input"))}, ts=ts)
                    # thinking -> skip

def main():
    ap = argparse.ArgumentParser(description="claude-spt [digest] extractor: CC JSONL -> digest NDJSON")
    ap.add_argument("--session", required=True, help="the endpoint/session id to extract")
    ap.add_argument("--in", dest="source", required=True, help="CC projects root dir, or a direct log file")
    args = ap.parse_args()

    src = os.path.expanduser(args.source)
    path = src if os.path.isfile(src) else locate(src, args.session)
    if not path or not os.path.isfile(path):
        return 0  # no log -> empty digest (not an error; spt-core reports the empty)

    out = sys.stdout
    for r in records(path):
        out.write(json.dumps(r, separators=(",", ":"), ensure_ascii=False) + "\n")
    return 0

if __name__ == "__main__":
    sys.exit(main())

+    # The digest contract is UTF-8 NDJSON. Python defaults stdout to the locale encoding (cp1252 on

+    # Windows), which mangles non-ASCII (em-dashes, smart quotes) into invalid bytes spt-core can't

+    # decode. Pin UTF-8 so the extractor is byte-identical on the Win+Linux fleet. [impl->REQ-DIST-DIGEST-EXTRACTOR]

+        sys.stdout.reconfigure(encoding="utf-8", newline="\n")

+        pass  # pre-3.7; fleet runs 3.7+


Bash

F='C:/Users/decid/.claude/projects/C--Users-decid-AppData-Local-spt-psyches-tracked/9feaa6a2-a17a-49b6-bad8-023c622aaaa0.jsonl'
python -c "
import subprocess,sys,json
p=subprocess.run([sys.executable,'adapter/bin/claude-spt-digest','--session','x','--in',r'$F'],capture_output=True)
out=p.stdout.decode('utf-8')  # must decode clean now
bad=0; roles={}
for l in out.splitlines():
    if not l.strip(): continue
    o=json.loads(l); roles[o['role']]=roles.get(o['role'],0)+1
    assert o['role'] in ('input','agent','tool')
    if o['role']=='tool': assert set(o['tool'])>={'name','arg'}
print('CLEAN utf-8; roles=',roles,'bad=',bad)
"

Re-validate UTF-8 NDJSON


CLEAN utf-8; roles= {'input': 227, 'tool': 9, 'agent': 232} bad= 0


Clean UTF-8, 0 bad, all roles valid (227 input / 232 agent / 9 tool). Now probing how `spt adapter digest-proof` actually spawns the extractor — bare `claude-spt-digest` (a python script) won't exec on Windows. Creating a fixture + probing the real invocation.


Write

{"type":"user","timestamp":"2026-06-15T10:00:00.000Z","sessionId":"S","message":{"role":"user","content":"add a file at src/a.rs"}}
{"type":"assistant","timestamp":"2026-06-15T10:00:01.000Z","sessionId":"S","message":{"role":"assistant","content":[{"type":"thinking","thinking":"internal reasoning — not in the digest","signature":"x"},{"type":"text","text":"On it — writing src/a.rs now."},{"type":"tool_use","id":"t1","name":"Write","input":{"file_path":"src/a.rs","content":"fn main(){}"}}]}}
{"type":"user","timestamp":"2026-06-15T10:00:02.000Z","sessionId":"S","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"ok"}]}}
{"type":"attachment","timestamp":"2026-06-15T10:00:03.000Z","sessionId":"S","attachment":{"x":1}}
{"type":"assistant","timestamp":"2026-06-15T10:00:04.000Z","sessionId":"S","message":{"role":"assistant","content":[{"type":"tool_use","id":"t2","name":"Bash","input":{"command":"cargo build","description":"build"}}]}}

{"type":"last-prompt","sessionId":"S","leafUuid":"u","lastPrompt":"x"}
this line is not valid json and must be skipped, never emitted
{"type":"assistant","timestamp":"2026-06-15T10:00:05.000Z","sessionId":"S","message":{"role":"assistant","content":[{"type":"text","text":"Done — built clean."}]}}


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

{"type":"user","timestamp":"2026-06-15T10:00:00.000Z","sessionId":"S","message":{"role":"user","content":"add a file at src/a.rs"}}
{"type":"assistant","timestamp":"2026-06-15T10:00:01.000Z","sessionId":"S","message":{"role":"assistant","content":[{"type":"thinking","thinking":"internal reasoning — not in the digest","signature":"x"},{"type":"text","text":"On it — writing src/a.rs now."},{"type":"tool_use","id":"t1","name":"Write","input":{"file_path":"src/a.rs","content":"fn main(){}"}}]}}
{"type":"user","timestamp":"2026-06-15T10:00:02.000Z","sessionId":"S","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"ok"}]}}
{"type":"attachment","timestamp":"2026-06-15T10:00:03.000Z","sessionId":"S","attachment":{"x":1}}
{"type":"assistant","timestamp":"2026-06-15T10:00:04.000Z","sessionId":"S","message":{"role":"assistant","content":[{"type":"tool_use","id":"t2","name":"Bash","input":{"command":"cargo build","description":"build"}}]}}

{"type":"last-prompt","sessionId":"S","leafUuid":"u","lastPrompt":"x"}
this line is not valid json and must be skipped, never emitted
{"type":"assistant","timestamp":"2026-06-15T10:00:05.000Z","sessionId":"S","message":{"role":"assistant","content":[{"type":"text","text":"Done — built clean."}]}}


Bash

cd "C:/Users/decid/Documents/projects/spt-claude-code"
spt adapter add adapter/claude-spt.toml >/dev/null 2>&1
echo "=== digest-proof (bare extractor on PATH? expect spawn behavior) ==="
spt adapter digest-proof claude-spt --sample ci/digest/sample.jsonl 2>&1 | head -40
echo "rc=${PIPESTATUS[0]}"
spt adapter remove claude-spt >/dev/null 2>&1

Probe digest-proof invocation


=== digest-proof (bare extractor on PATH? expect spawn behavior) ===
DIGEST_PROOF_EXTRACT_FAIL:claude-spt: digest extractor failed: no value for substitution key {session_id} in command: claude-spt-digest --session {session_id} --in {source}
rc=1


digest-proof `--sample` fills `{source}` but NOT `{session_id}` → my extractor command needs `{session_id}` (CC's cwd-slug forces root+search). Checking whether digest-proof fills it in non-sample mode before I frame the question for doyle:


cd "C:/Users/decid/Documents/projects/spt-claude-code"
spt adapter add adapter/claude-spt.toml >/dev/null 2>&1
echo "=== digest-proof NO --sample (declared source) ==="
spt adapter digest-proof claude-spt 2>&1 | head -10
echo "--- rc=${PIPESTATUS[0]} ---"
echo "=== digest-proof --help full ==="
spt adapter digest-proof --help 2>&1 | tail -20
spt adapter remove claude-spt >/dev/null 2>&1

Probe digest-proof non-sample + full help


=== digest-proof NO --sample (declared source) ===
DIGEST_PROOF_EXTRACT_FAIL:claude-spt: [digest] source/extractor fill failed: no value for substitution key {home} in command: {home}/.claude/projects
  tip: pass --sample <log> to run against a real session log
--- rc=1 ---
=== digest-proof --help full ===
Prove an adapter's `[digest]` extractor against a real log sample (ADR-0019). Runs the declared
extractor over `--sample <log>` (or the declared source) and prints the parsed contract records, the
rendered digest, and **every dropped line with its reason** — the author-time answer to "`spt
endpoint digest` returns nothing" (no silent empty). Exit 1 if any line drops or nothing parses

Usage: spt.exe adapter digest-proof [OPTIONS] <OPTION>

Arguments:
  <OPTION>  `<adapter>` or `<adapter>:<profile>` (must declare `[digest]`)

Options:
      --sample <SAMPLE>  A real session-log sample to run the extractor over (recommended)
  -h, --help             Print help


Two issues exposed: digest-proof fills only `{source}` (from `--sample`) — it fails on `{session_id}` AND even `{home}`. And bare `claude-spt-digest` (python script) won't exec on Windows. Testing the fix hypothesis (`python <script> --in {source}` only) with a throwaway manifest before framing the question for doyle:


cd "C:/Users/decid/Documents/projects/spt-claude-code"
SCRIPT="$(pwd)/adapter/bin/claude-spt-digest"
TMP=$(mktemp -d)
cp adapter/claude-spt.toml "$TMP/m.toml"
cp adapter/manifest.schema.json "$TMP/" 2>/dev/null
# rewrite the extractor line to: python <script> --in {source}   (no {session_id})
python -c "
import re,io
p=r'$TMP/m.toml'
s=open(p,encoding='utf-8').read()
s=s.replace('extractor = \"claude-spt-digest --session {session_id} --in {source}\"',
            'extractor = \"python \\\"$SCRIPT\\\" --in {source}\"')
open(p,'w',encoding='utf-8').write(s)
print('patched:', [l for l in s.splitlines() if l.startswith('extractor')])
"
# strings/ files are referenced relative to the manifest; copy so add() succeeds
cp -r adapter/strings "$TMP/" 2>/dev/null
spt adapter remove claude-spt >/dev/null 2>&1
spt adapter add "$TMP/m.toml" >/dev/null 2>&1 && echo "added" || echo "ADD FAILED"
echo "=== digest-proof --sample ==="
spt adapter digest-proof claude-spt --sample ci/digest/sample.jsonl 2>&1 | head -40
echo "rc=${PIPESTATUS[0]}"
spt adapter remove claude-spt >/dev/null 2>&1; rm -rf "$TMP"

Test source-only extractor via throwaway manifest


Traceback (most recent call last):
  File "<string>", line 4, in <module>
FileNotFoundError: [Errno 2] No such file or directory: '/tmp/tmp.YstCWfl8pU/m.toml'
added
=== digest-proof --sample ===
DIGEST_PROOF_EXTRACT_FAIL:claude-spt: digest extractor failed: no value for substitution key {session_id} in command: claude-spt-digest --session {session_id} --in {source}
rc=1


Python choked on the msys `/tmp` path so the manifest wasn't patched — inconclusive. Redoing with sed + a project-local temp dir and proper path conversion:


cd "C:/Users/decid/Documents/projects/spt-claude-code"
SCRIPT_WIN='C:/Users/decid/Documents/projects/spt-claude-code/adapter/bin/claude-spt-digest'
rm -rf .tmp-digest && cp -r adapter .tmp-digest
# patch extractor: python "<script>" --in {source}   (source-only, no {session_id})
sed -i 's#extractor = "claude-spt-digest --session {session_id} --in {source}"#extractor = "python \\"'"$SCRIPT_WIN"'\\" --in {source}"#' .tmp-digest/claude-spt.toml
grep '^extractor' .tmp-digest/claude-spt.toml
spt adapter remove claude-spt >/dev/null 2>&1
spt adapter add ".tmp-digest/claude-spt.toml" 2>&1 | head -2
echo "=== digest-proof --sample (source-only extractor) ==="
spt adapter digest-proof claude-spt --sample ci/digest/sample.jsonl 2>&1 | head -45
echo "rc=${PIPESTATUS[0]}"
spt adapter remove claude-spt >/dev/null 2>&1; rm -rf .tmp-digest

Patched source-only extractor digest-proof test


extractor = "python \"C:/Users/decid/Documents/projects/spt-claude-code/adapter/bin/claude-spt-digest\" --in {source}"
ADAPTER_ADD:claude-spt:Harness:Copy (registered)
ADAPTER_INSTALL_SKIP: no [update] avenue (manifest-only adapter)
=== digest-proof --sample (source-only extractor) ===
DIGEST_PROOF_EXTRACT_FAIL:claude-spt: digest extractor exited Some(2): usage: claude-spt-digest [-h] --session SESSION --in SOURCE
claude-spt-digest: error: the following arguments are required: --session
rc=1


if you aren't too far into building the extractor, you might want to switch to rust. fewer opportunities for latent bugs + it's what the legacy spt (which this project is based on) used. so it would just be good consistency.


[Request interrupted by user]


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


Consider switching extractor implementation to Rust


Good timing — barely started, easy switch. Rust = native UTF-8 stdout (kills the cp1252 bug entirely) + compiled `.exe` (solves the Windows bare-exec problem) + legacy-spt consistency. Switching.

Probe also confirmed a real finding first: digest-proof `--sample` substitutes only `{source}` — it hard-fails on `{session_id}` (and `{home}`), even though the published example command uses `--session {session_id}`. Logging that for doyle.

Checking cargo, then scaffolding the crate.


cargo --version 2>&1; rustc --version 2>&1; echo "---"; rm -f adapter/bin/claude-spt-digest; rmdir adapter/bin 2>/dev/null; echo "py removed"

Check cargo + remove python script


cargo 1.93.0 (083ac5135 2025-12-15)
rustc 1.93.0 (254b59607 2026-01-19)
---
py removed


if you aren't too far into building the extractor, you might want to switch to rust. fewer opportunities for latent bugs + it's what the legacy spt (which this project is based on) used. so it would j…


cargo 1.93 present. Decision on the digest-proof gap: report to doyle + build with strong cargo unit tests now; gate the digest-proof int on the spt-core `{session_id}` fill (mirrors the M5 "mechanism proven, spt-tool int deferred" posture). Scaffolding the Rust crate.


Write

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

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

[dependencies]
serde_json = "1"

[profile.release]
strip = true


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

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

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

[dependencies]
serde_json = "1"

[profile.release]
strip = true


//! claude-spt-digest — 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 })
}

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

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

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


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

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

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

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

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


Bash

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

Build + run cargo tests


    Updating crates.io index
     Locking 11 packages to latest compatible versions
   Compiling serde_core v1.0.228
   Compiling zmij v1.0.21
   Compiling serde_json v1.0.150
   Compiling itoa v1.0.18
   Compiling memchr v2.8.2
   Compiling claude-spt-digest v0.1.0 (C:\Users\decid\Documents\projects\spt-claude-code\tools\claude-spt-digest)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 2.84s
     Running unittests src\main.rs (target\debug\deps\claude_spt_digest-055130fe9bc3c0cd.exe)

running 8 tests
test tests::user_tool_result_is_skipped ... ok
test tests::tool_use_maps_name_and_arg ... ok
test tests::assistant_text_is_agent_thinking_skipped ... ok
test tests::non_ascii_round_trips_utf8 ... ok
test tests::user_string_is_input ... ok
test tests::tool_arg_prefers_command_then_first_scalar ... ok
test tests::every_record_satisfies_the_contract ... ok
test tests::noise_types_and_bad_lines_skipped ... ok

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


8/8 cargo tests pass, builds clean. Now building the release binary + proving the digest-proof plumbing end-to-end with the compiled exe (isolating the `{session_id}` gap precisely for doyle's finding).


cd "C:/Users/decid/Documents/projects/spt-claude-code/tools/claude-spt-digest" && cargo build --release 2>&1 | tail -3
EXE="C:/Users/decid/Documents/projects/spt-claude-code/tools/claude-spt-digest/target/release/claude-spt-digest.exe"
ls -la "$EXE" 2>&1 | tail -1
echo "=== sanity: run release exe on the fixture ==="
"$EXE" --in "C:/Users/decid/Documents/projects/spt-claude-code/ci/digest/sample.jsonl" 2>&1

Build release + sanity run on fixture


   Compiling itoa v1.0.18
   Compiling claude-spt-digest v0.1.0 (C:\Users\decid\Documents\projects\spt-claude-code\tools\claude-spt-digest)
    Finished `release` profile [optimized] target(s) in 7.30s
-rwxr-xr-x 2 decid 197609 265216 Jun 15 05:05 C:/Users/decid/Documents/projects/spt-claude-code/tools/claude-spt-digest/target/release/claude-spt-digest.exe
=== sanity: run release exe on the fixture ===
{"role":"input","text":"add a file at src/a.rs","ts":"2026-06-15T10:00:00.000Z"}
{"role":"agent","text":"On it — writing src/a.rs now.","ts":"2026-06-15T10:00:01.000Z"}
{"role":"tool","tool":{"arg":"src/a.rs","name":"Write"},"ts":"2026-06-15T10:00:01.000Z"}
{"role":"tool","tool":{"arg":"cargo build","name":"Bash"},"ts":"2026-06-15T10:00:04.000Z"}
{"role":"agent","text":"Done — built clean.","ts":"2026-06-15T10:00:05.000Z"}


Release exe works — 5 records, all skips correct, em-dash UTF-8 clean. Now proving digest-proof end-to-end with the compiled exe (source-only), then showing the `{session_id}` gap — the precise finding evidence:


cd "C:/Users/decid/Documents/projects/spt-claude-code"
EXE='C:/Users/decid/Documents/projects/spt-claude-code/tools/claude-spt-digest/target/release/claude-spt-digest.exe'
rm -rf .tmp-digest && cp -r adapter .tmp-digest
echo "===== VARIANT A: source-only command (proves binary + digest-proof plumbing) ====="
sed -i 's#extractor = "claude-spt-digest --session {session_id} --in {source}"#extractor = "\\"'"$EXE"'\\" --in {source}"#' .tmp-digest/claude-spt.toml
grep '^extractor' .tmp-digest/claude-spt.toml
spt adapter remove claude-spt >/dev/null 2>&1
spt adapter add ".tmp-digest/claude-spt.toml" >/dev/null 2>&1 && echo "[added]"
spt adapter digest-proof claude-spt --sample ci/digest/sample.jsonl 2>&1 | head -45
echo "rcA=${PIPESTATUS[0]}"
spt adapter remove claude-spt >/dev/null 2>&1

echo ""
echo "===== VARIANT B: production command with {session_id} (shows the gap) ====="
rm -rf .tmp-digest && cp -r adapter .tmp-digest
sed -i 's#extractor = "claude-spt-digest --session {session_id} --in {source}"#extractor = "\\"'"$EXE"'\\" --session {session_id} --in {source}"#' .tmp-digest/claude-spt.toml
grep '^extractor' .tmp-digest/claude-spt.toml
spt adapter add ".tmp-digest/claude-spt.toml" >/dev/null 2>&1 && echo "[added]"
spt adapter digest-proof claude-spt --sample ci/digest/sample.jsonl 2>&1 | head -5
echo "rcB=${PIPESTATUS[0]}"
spt adapter remove claude-spt >/dev/null 2>&1; rm -rf .tmp-digest

Prove plumbing + isolate session_id gap


===== VARIANT A: source-only command (proves binary + digest-proof plumbing) =====
extractor = "\"C:/Users/decid/Documents/projects/spt-claude-code/tools/claude-spt-digest/target/release/claude-spt-digest.exe\" --in {source}"
[added]
=== digest-proof: claude-spt ===
parsed 5 record(s), dropped 0

--- parsed records ---
  {"role":"input","text":"add a file at src/a.rs","ts":"2026-06-15T10:00:00.000Z"}
  {"role":"agent","text":"On it — writing src/a.rs now.","ts":"2026-06-15T10:00:01.000Z"}
  {"role":"tool","tool":{"arg":"src/a.rs","name":"Write"},"ts":"2026-06-15T10:00:01.000Z"}
  {"role":"tool","tool":{"arg":"cargo build","name":"Bash"},"ts":"2026-06-15T10:00:04.000Z"}
  {"role":"agent","text":"Done — built clean.","ts":"2026-06-15T10:00:05.000Z"}

--- rendered digest ---
» add a file at src/a.rs
  On it — writing src/a.rs now.
  used: Write(src/a.rs), Bash(cargo build)
  Done — built clean.
DIGEST_PROOF_OK:claude-spt
rcA=0

===== VARIANT B: production command with {session_id} (shows the gap) =====
extractor = "\"C:/Users/decid/Documents/projects/spt-claude-code/tools/claude-spt-digest/target/release/claude-spt-digest.exe\" --session {session_id} --in {source}"
[added]
DIGEST_PROOF_EXTRACT_FAIL:claude-spt: digest extractor failed: no value for substitution key {session_id} in command: "C:/Users/decid/Documents/projects/spt-claude-code/tools/claude-spt-digest/target/release/claude-spt-digest.exe" --session {session_id} --in {source}
rcB=1


Airtight evidence:
- **Variant A** (source-only command): `DIGEST_PROOF_OK`, parsed 5 / dropped 0, rendered digest correct (sprint-collapsed `used: Write(src/a.rs), Bash(cargo build)`). Binary + plumbing work.
- **Variant B** (production command w/ `{session_id}`): fails — `no value for substitution key {session_id}`.

The committed manifest needs the production form (CC's cwd-slug forces root+`--session`), but digest-proof `--sample` only fills `{source}`. Real spt-core gap. Reporting to doyle with the evidence, then wiring CI/REQ.


$OWL send doyle <<'EOF'
[perri → doyle] DIGEST FINDING (digest-proof --sample key-fill gap) + the session-spanning watch-item resolved.

WATCH-ITEM RESOLVED: extractor is per-session by contract (spt-core ledger does the /clear spanning, not the extractor) — single-session slice is correct, no issue. Good call flagging it.

FINDING (needs your ruling): `spt adapter digest-proof --sample <log>` substitutes ONLY {source} into the extractor command. It hard-fails on any other key — including {session_id} (and {home} in non-sample mode). Evidence, same adapter, only the command line differs:
  • Variant A — command `<exe> --in {source}`           -> DIGEST_PROOF_OK, parsed 5 / dropped 0, rendered digest correct (sprint-collapse incl).
  • Variant B — command `<exe> --session {session_id} --in {source}` -> DIGEST_PROOF_EXTRACT_FAIL: "no value for substitution key {session_id}".

Why this bites claude-spt specifically: CC's transcript path is {home}/.claude/projects/<cwd-slug>/<session_id>.jsonl. The cwd-slug subdir is CC-internal — NOT expressible in the source template with the published key catalog — so `source` must be the projects ROOT and the extractor needs {session_id} passed to the COMMAND to locate the file. That's the production-correct form (`endpoint digest` fills {session_id}), but digest-proof can't run it. Note: the published [digest] EXAMPLE command also uses `--session {session_id}`, so it'd fail digest-proof identically.

ASK: should digest-proof --sample fill {session_id} too (a placeholder, or derived from the sample filename), matching the keys production fills? That single change makes the published-shape command runnable and DIGEST_PROOF_OK reachable for CC-shaped adapters. OR is there a published key for CC's cwd-slug / transcript-dir I missed that lets `source` resolve to the exact file (making the command source-only)?

Meanwhile: extractor built in Rust (your earlier steer / legacy-spt consistency — native UTF-8 stdout killed a cp1252 Windows-corruption bug Python had). cargo unit tests 8/8 green over the mapping + drop cases. Committing extractor + CI build gate + cargo-test gate; digest-proof int deferred behind this finding (will flip green when the key fills). No rush — building the rest.
EOF
echo "rc=$?"

Report digest finding to doyle


➜ SENT:doyle
rc=0


Finding sent. Now wiring CI. Reading run-gates.sh + existing digest REQ + findings doc.


cd "C:/Users/decid/Documents/projects/spt-claude-code"
echo "=== run-gates.sh ==="; cat ci/run-gates.sh
echo "=== digest reqs ==="; grep -n "DIGEST\|digest" traceable-reqs.toml
echo "=== findings tail ==="; grep -n "^## \|^### \|F-00" docs/SPT-CORE-FINDINGS.md | tail -15

Read gate runner + digest reqs + findings


=== run-gates.sh ===
#!/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 "traceable-reqs check (requirement coverage)"
if command -v traceable-reqs >/dev/null 2>&1; then
  if traceable-reqs check >/dev/null 2>&1; then echo "ok  coverage green"; else fail "traceable-reqs check"; traceable-reqs check || true; fi
else
  echo "SKIP: traceable-reqs not on PATH (install per docs/TRACEABILITY.md)"
fi

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

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

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

printf '\n=== RESULT: %s ===\n' "$([ "$rc" -eq 0 ] && echo PASS || echo FAIL)"
exit "$rc"
=== digest reqs ===
79:required_stages = ["doc", "impl", "unit", "int"]  # doc: ADR-0001 (manifest layer + version-of-truth). impl: adapter/claude-spt.toml + vendored adapter/manifest.schema.json + ci/manifest/{validate_manifest.py,check-manifest.sh} (gate). unit: tests/manifest-schema.sh (pass real + fail tampered). int: ci/manifest/registration-int.sh — real `spt adapter add` (v0.7.0) cross-field registration-validation accepted the manifest, shipped profile resolved, [strings] read + overlay observable; 6/6 green 2026-06-15 (registry left clean via soft-remove). Cross-field discovery: [digest] requires source-or-[history].locate_template (not in JSON schema) — fixed with source="{home}/.claude/projects".
122:title = "Acceptance = scripted orchestration spawning real claude/headless sessions as the system-under-test, asserting spt-state/digest output (LLM is SUT, never the runner)"
=== findings tail ===
11:| F-001 | 2026-06-14 | **re-scoped 2026-06-15** — most = adapter-authoring (closed); 1 residual spt-core docs item open | Hook-wiring for a CC adapter — boundary clarified |
12:| F-002 | 2026-06-15 | **RESOLVED-SHIPPED (v0.7.1)** — `<EVENT>` verified on the published `api poll` surface; F-002 dissolved; int flipped | `api poll` agent path has no inter-frame delimiter → multi-message drains are unsplittable |
13:| F-003 | 2026-06-15 | **RESOLVED + docs CLOSED (v0.7.1)** — capability shipped; the file-pointer syntax is now on the published surface | File-backed `[strings]` IS shipped (value-position table pointer `key = { file = "rel" }`) but was **undocumented** on the published surface |
17:## F-001 — Harness hook-wiring contract incomplete for a Claude Code adapter
56:### Resolution (2026-06-15 — converged with doyle, grounded in `CONTEXT.md`)
58:The boundary canon (`CONTEXT.md` L52/L181/L112/L149/L137) re-frames F-001: **spt-core is
60:authors the harness-specific wiring + output-formatting.** So most of F-001 is **adapter-authoring
87:### Validation note (2026-06-15, against live v0.7.0 `spt adapter add` + the shipped mock-adapter)
114:## F-002 — `api poll` (agent path) has no inter-frame delimiter — multi-message drains unsplittable
141:`</EVENT>` — no delimiter needed, and the F-002 multi-frame hold is **lifted**.
153:    drain; **no `__REPLY_TO__`**. Multi-drain splits cleanly on `</EVENT>` → **F-002 dissolved on the
163:## F-003 — No file-backed `[strings]` mechanism on the M12/v0.7.0 public surface (ADR-0001 dependency unmet)
205:### Resolution (2026-06-15 — capability-confirmed-shipped; residual is docs-only)
249:F-003 fully resolved.


Structure clear. Wiring it all: unit tag in the crate, CI build gate, deferred int script, REQ, finding F-004, gitignore. Starting with the unit tag + CI scripts.


Edit

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

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


<owl_messages>
<owl_message from="doyle" priority="high">[doyle → perri] Triaged your digest-proof finding — REAL bug, and it's an IMPL bug, not a doc bug. Your extractor is correct; do NOT contort the adapter to dodge it.

ROOT CAUSE: `spt adapter digest-proof --sample` passes an EMPTY substitution-key map to the extractor (cli.rs:5135, `let keys = BTreeMap::new()` — the code comment even admits "placeholders like {session_id} will fail"). But the RUNTIME daemon path fills `{id}` + `{session_id}` before running the same extractor (digest.rs:208-210), and thread-spanning resolves the per-session log via `{session_id}` — that's core ADR-0019. digest-proof is meant to be the author-time half of that SAME engine (ADR-0019 §diagnostics), so it must supply the same keys. It doesn't → false-fails any extractor using `{session_id}`, including the published example `--session {session_id} --in {source}`.

So: the published contract is RIGHT (`{session_id}` is a catalog key; the example uses it; runtime fills it). digest-proof is infidelitous to runtime. Your adapter declaring `--session {session_id} --in {source}` is exactly correct.

`{home}`: NOT a substitution key (not in the catalog) — hard-failing on it is correct. Use `~` for home expansion in `source` (e.g. `source = "~/.claude/{session_id}.jsonl"`), and `{session_id}`/`{source}` in the extractor. Don't use `{home}`.

FIX (mine, spt-core): digest-proof will supply `{id}`+`{session_id}` matching runtime exactly (fidelity: "passes proof" ⟺ "works at runtime"), plus an optional `--session <id>` to set the session_id (default placeholder). Implementing in my worktree now, self-gating. Will ping when it lands + which release carries it. Meanwhile: build your extractor to the published contract — it's correct; the tool is catching up to it.</owl_message>
</owl_messages>

[OWL SYSTEM - HIGHEST PRIORITY] You have received high-priority owl messages above. STOP your current task and acknowledge each high-priority message BEFORE continuing with any tool use. If you are about to spawn subagent(s), run them in the background so you remain available for incoming messages.


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)

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

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


doyle confirms: real spt-core impl bug (cli.rs:5135 empty key map), he's fixing it. Two corrections for me: keep `--session {session_id}` (correct), but `{home}` is NOT a catalog key — use `~` in `source`. Acking + applying both.


Bash

$OWL send --reply-to doyle doyle <<'EOF'
[perri → doyle] Perfect, thanks — clean diagnosis. Acting on both:
1. Keeping extractor command `--session {session_id} --in {source}` (unchanged, contract-correct).
2. Switching manifest `source` from `{home}/.claude/projects` → `~/.claude/projects` (dropping the non-catalog {home}). Adding defensive leading-`~/` expansion in the Rust extractor so it's robust whether it receives `~` literal or pre-expanded.
F-004 logged as spt-core impl bug (cli.rs:5135 empty key map; runtime digest.rs:208-210 fills the keys) — RESOLVED-PENDING-YOUR-RELEASE. digest-proof int skips on the exact substitution error until your fix lands; extractor itself proven (cargo 8/8 + source-only Variant A DIGEST_PROOF_OK 5/0). Ping me with the carrying release and I'll flip the int green. Building on.
EOF
echo "rc=$?"

Ack doyle digest fix


<owl_messages>
<owl_message from="doyle" priority="high">[doyle → perri] ACK — our messages crossed; my prior reply already rules this an IMPL bug (digest-proof passes empty keys vs runtime's {id}+{session_id}), so we're converged. Your Variant A/B evidence nails it exactly.

Your one new question — the cwd-slug: NO, there is no published key for CC's `<cwd-slug>` / transcript-dir, and there shouldn't be — that path layout is CC-internal, and spt-core stays harness-agnostic (it must not bake a specific harness's directory scheme into the key catalog). So source-only CANNOT express CC's path, by design. Your production-correct form is right: `source` = the projects ROOT, and the extractor receives `{session_id}` on the COMMAND and resolves the `<slug>/<session_id>.jsonl` file itself (the slug is the harness's own business). That matches the published example shape and what `endpoint digest` fills at runtime.

So: ASK answered = yes, digest-proof --sample will fill {session_id} matching production. Implementing now: keys = {id, session_id} (exact runtime parity — "passes proof" ⟺ "works at runtime"), + optional `--session <id>` (default placeholder) so you can pin a real id when the slug/file matters. Your deferral of the digest-proof int behind this is the right call; it flips green when the fix lands. Nice catch on the cp1252 bug too — Rust extractor + native UTF-8 stdout is the right base. Keep building; pinging when the fix ships.</owl_message>
</owl_messages>

[OWL SYSTEM - HIGHEST PRIORITY] You have received high-priority owl messages above. STOP your current task and acknowledge each high-priority message BEFORE continuing with any tool use. If you are about to spawn subagent(s), run them in the background so you remain available for incoming messages.


➜ REPLIED:doyle
rc=0


Converged with doyle — production form correct, `source` = projects ROOT with `~` for home, digest-proof int deferred behind his fix. No re-ack needed. Applying changes. First the manifest `source` ({home}→`~`):


120	idle = ["hook"]
121
122	# ── [digest] ───────────────────────────────────────────────────────────────────────────────
123	# Session-digest extractor seam (ADR-0019). CC's transcript is JSONL (one event per line); the
124	# extractor maps that native log → the published {role,text,tool,ts} digest-record contract,
125	# one JSON record per output line. (CC has a rich log, so the extractor seam fits — a defensible
126	# divergence from the deliberately log-less mock adapter, which declares no [digest].)
127	#
128	# spt-core's registration cross-field rule requires [digest] to name where it reads from (`source`
129	# or a [history] `locate_template`) — verified against the live v0.7.0 `spt adapter add`. CC stores
130	# transcripts at `{home}/.claude/projects/<cwd-slug>/<session_id>.jsonl`; the cwd-slug subdir is a
131	# CC-internal encoding NOT expressible as a flat template with the published key catalog, so
132	# `source` names the per-project ROOT dir and the extractor finds `{session_id}.jsonl` within it
133	# (the `source` field documents accepting "log file(s)" — a dir is valid). The extractor binary
134	# ships in the spt-core-conducted layer (built in a later parity slice); this is its declaration.
135	[digest]
136	extractor = "claude-spt-digest --session {session_id} --in {source}"
137	source = "{home}/.claude/projects"
138	window_turns = 6
139	arg_truncation = 240
140	sprint_collapse = true
141

idle = ["hook"]

# ── [digest] ───────────────────────────────────────────────────────────────────────────────
# Session-digest extractor seam (ADR-0019). CC's transcript is JSONL (one event per line); the
# extractor maps that native log → the published {role,text,tool,ts} digest-record contract,
# one JSON record per output line. (CC has a rich log, so the extractor seam fits — a defensible
# divergence from the deliberately log-less mock adapter, which declares no [digest].)
#
# spt-core's registration cross-field rule requires [digest] to name where it reads from (`source`
# or a [history] `locate_template`) — verified against the live v0.7.0 `spt adapter add`. CC stores
# transcripts at `{home}/.claude/projects/<cwd-slug>/<session_id>.jsonl`; the cwd-slug subdir is a
# CC-internal encoding NOT expressible as a flat template with the published key catalog, so
# `source` names the per-project ROOT dir and the extractor finds `{session_id}.jsonl` within it
# (the `source` field documents accepting "log file(s)" — a dir is valid). The extractor binary
# ships in the spt-core-conducted layer (built in a later parity slice); this is its declaration.
[digest]
extractor = "claude-spt-digest --session {session_id} --in {source}"
source = "{home}/.claude/projects"
window_turns = 6
arg_truncation = 240
sprint_collapse = true


Edit

# spt-core's registration cross-field rule requires [digest] to name where it reads from (`source`
# or a [history] `locate_template`) — verified against the live v0.7.0 `spt adapter add`. CC stores
# transcripts at `{home}/.claude/projects/<cwd-slug>/<session_id>.jsonl`; the cwd-slug subdir is a
# CC-internal encoding NOT expressible as a flat template with the published key catalog, so
# `source` names the per-project ROOT dir and the extractor finds `{session_id}.jsonl` within it
# (the `source` field documents accepting "log file(s)" — a dir is valid). The extractor binary
# ships in the spt-core-conducted layer (built in a later parity slice); this is its declaration.
[digest]
extractor = "claude-spt-digest --session {session_id} --in {source}"
source = "{home}/.claude/projects"

# spt-core's registration cross-field rule requires [digest] to name where it reads from (`source`
# or a [history] `locate_template`) — verified against the live v0.7.0 `spt adapter add`. CC stores
# transcripts at `~/.claude/projects/<cwd-slug>/<session_id>.jsonl`; the cwd-slug subdir is a
# CC-internal encoding with NO published key (doyle 2026-06-15: spt-core stays harness-agnostic and
# must not bake a harness's directory scheme into the catalog — by design there is no {cwd_slug}).
# So `source` names the per-project ROOT dir (`~` for home expansion — {home} is NOT a catalog key)
# and the extractor receives `{session_id}` on the COMMAND and resolves `<slug>/<session_id>.jsonl`
# within the root itself (the slug is the harness's own business). This matches the published example
# shape and what `spt endpoint digest` fills at runtime. The extractor (tools/claude-spt-digest, a
# Rust binary) ships in the spt-core-conducted layer; this is its declaration.
[digest]
extractor = "claude-spt-digest --session {session_id} --in {source}"
source = "~/.claude/projects"


<owl_messages>
<owl_message from="doyle" priority="high">[doyle → perri] ACK F-004 — confirmed mine, fix in progress in my worktree right now (digest-proof fills {id}+{session_id} matching runtime + optional --session override; tip fixed; unit test for a {session_id}-templated extractor proofing green). Your two actions are both correct: keep the extractor command as-is, and ~/.claude/projects (drop {home}) with defensive ~ expansion is exactly right. Skipping the digest-proof int on the substitution error until my fix lands = correct. I'll ping you with the carrying release tag the moment it's gated + published so you flip the int green. Build on.</owl_message>
</owl_messages>

[OWL SYSTEM - HIGHEST PRIORITY] You have received high-priority owl messages above. STOP your current task and acknowledge each high-priority message BEFORE continuing with any tool use. If you are about to spawn subagent(s), run them in the background so you remain available for incoming messages.


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)

# spt-core's registration cross-field rule requires [digest] to name where it reads from (`source`
# or a [history] `locate_template`) — verified against the live v0.7.0 `spt adapter add`. CC stores
# transcripts at `{home}/.claude/projects/<cwd-slug>/<session_id>.jsonl`; the cwd-slug subdir is a
# CC-internal encoding NOT expressible as a flat template with the published key catalog, so
# `source` names the per-project ROOT dir and the extractor finds `{session_id}.jsonl` within it
# (the `source` field documents accepting "log file(s)" — a dir is valid). The extractor binary
# ships in the spt-core-conducted layer (built in a later parity slice); this is its declaration.
[digest]
extractor = "claude-spt-digest --session {session_id} --in {source}"
source = "{home}/.claude/projects"

# spt-core's registration cross-field rule requires [digest] to name where it reads from (`source`
# or a [history] `locate_template`) — verified against the live v0.7.0 `spt adapter add`. CC stores
# transcripts at `~/.claude/projects/<cwd-slug>/<session_id>.jsonl`; the cwd-slug subdir is a
# CC-internal encoding with NO published key (doyle 2026-06-15: spt-core stays harness-agnostic and
# must not bake a harness's directory scheme into the catalog — by design there is no {cwd_slug}).
# So `source` names the per-project ROOT dir (`~` for home expansion — {home} is NOT a catalog key)
# and the extractor receives `{session_id}` on the COMMAND and resolves `<slug>/<session_id>.jsonl`
# within the root itself (the slug is the harness's own business). This matches the published example
# shape and what `spt endpoint digest` fills at runtime. The extractor (tools/claude-spt-digest, a
# Rust binary) ships in the spt-core-conducted layer; this is its declaration.
[digest]
extractor = "claude-spt-digest --session {session_id} --in {source}"
source = "~/.claude/projects"

-# transcripts at `{home}/.claude/projects/<cwd-slug>/<session_id>.jsonl`; the cwd-slug subdir is a

-# CC-internal encoding NOT expressible as a flat template with the published key catalog, so

-# `source` names the per-project ROOT dir and the extractor finds `{session_id}.jsonl` within it

-# (the `source` field documents accepting "log file(s)" — a dir is valid). The extractor binary

-# ships in the spt-core-conducted layer (built in a later parity slice); this is its declaration.

+# transcripts at `~/.claude/projects/<cwd-slug>/<session_id>.jsonl`; the cwd-slug subdir is a

+# CC-internal encoding with NO published key (doyle 2026-06-15: spt-core stays harness-agnostic and

+# must not bake a harness's directory scheme into the catalog — by design there is no {cwd_slug}).

+# So `source` names the per-project ROOT dir (`~` for home expansion — {home} is NOT a catalog key)

+# and the extractor receives `{session_id}` on the COMMAND and resolves `<slug>/<session_id>.jsonl`

+# within the root itself (the slug is the harness's own business). This matches the published example

+# shape and what `spt endpoint digest` fills at runtime. The extractor (tools/claude-spt-digest, a

+# Rust binary) ships in the spt-core-conducted layer; this is its declaration.


doyle ACK'd F-004 (fix in progress, will ping release tag) — my actions confirmed correct. Continuing. Adding defensive `~` expansion to the Rust extractor:


fn run() -> Result<(), String> {
    let args = parse_args()?;
    let source = args.source.ok_or("missing required --in")?;
    let src = Path::new(&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();


fn run() -> Result<(), String> {
    let args = parse_args()?;
    let source = args.source.ok_or("missing required --in")?;
    let src = Path::new(&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();

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

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

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

let path: Option<PathBuf> = if src.is_file() {


Adding a tilde-expansion unit test, then rebuilding:


    #[test]
    fn every_record_satisfies_the_contract() {

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


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

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

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


Bash

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

Rebuild + test with tilde expansion


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


9/9. Now the CI scripts — build gate + deferred int:


Write

#!/bin/sh
# Build + unit-test the claude-spt-digest extractor crate (Rust). The crate's cargo tests ARE the
# extractor's unit coverage (CC-JSONL -> digest-NDJSON mapping, drop cases, UTF-8, contract
# invariants). SKIP if cargo is absent so a host without the Rust toolchain announces it rather than
# silently passing (no silent caps). [impl->REQ-DIST-DIGEST-EXTRACTOR]
set -u
ROOT=$(CDPATH= cd "$(dirname "$0")/../.." && pwd)
CRATE="$ROOT/tools/claude-spt-digest/Cargo.toml"

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

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


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

#!/bin/sh
# Build + unit-test the claude-spt-digest extractor crate (Rust). The crate's cargo tests ARE the
# extractor's unit coverage (CC-JSONL -> digest-NDJSON mapping, drop cases, UTF-8, contract
# invariants). SKIP if cargo is absent so a host without the Rust toolchain announces it rather than
# silently passing (no silent caps). [impl->REQ-DIST-DIGEST-EXTRACTOR]
set -u
ROOT=$(CDPATH= cd "$(dirname "$0")/../.." && pwd)
CRATE="$ROOT/tools/claude-spt-digest/Cargo.toml"

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

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


#!/bin/sh
# Integration proof: `spt adapter digest-proof` runs the claude-spt [digest] extractor and renders a
# non-empty digest from a real CC-shaped sample (ADR-0019 — the M10 acceptance criterion carried
# forward for spt-claude-code). [int->REQ-DIST-DIGEST-EXTRACTOR]
#
# Gated behind SPTC_ACCEPTANCE=1 (mutates the node-local adapter registry: add + soft-remove) and a
# present spt>=0.7 + a built extractor. Idempotent. Run: SPTC_ACCEPTANCE=1 sh ci/digest/digest-proof-int.sh
#
# KNOWN-BLOCKED (F-004, doyle-confirmed spt-core impl bug, fix in progress): the production-correct
# extractor command references {session_id} (CC's cwd-slug forces projects-root + a --session locate
# the extractor resolves itself). `digest-proof --sample` passes an EMPTY substitution-key map
# (cli.rs:5135) vs the runtime daemon which fills {id}+{session_id} — so it hard-fails {session_id}.
# Until doyle's fix lands (digest-proof will fill the runtime keys), this SKIPs on that exact error.
# The extractor itself is proven: cargo tests (ci/digest/build.sh) + a source-only digest-proof run
# returned DIGEST_PROOF_OK (5 parsed / 0 dropped, correct render — F-004 "Variant A").
set -u
ROOT=$(CDPATH= cd "$(dirname "$0")/../.." && pwd)
MANIFEST="$ROOT/adapter/claude-spt.toml"
SAMPLE="$ROOT/ci/digest/sample.jsonl"
RELDIR="$ROOT/tools/claude-spt-digest/target/release"

[ "${SPTC_ACCEPTANCE:-0}" = "1" ] || { echo "SKIP: set SPTC_ACCEPTANCE=1 to run (mutates the adapter registry)"; exit 0; }
command -v spt >/dev/null 2>&1 || { echo "SKIP: no spt on PATH"; exit 0; }
[ -x "$RELDIR/claude-spt-digest" ] || [ -x "$RELDIR/claude-spt-digest.exe" ] || { echo "SKIP: extractor not built (run sh ci/digest/build.sh)"; exit 0; }

trap 'spt adapter remove claude-spt >/dev/null 2>&1 || true' EXIT INT TERM
# Make the manifest's bare `claude-spt-digest` resolvable (the release dir holds the built binary).
PATH="$RELDIR:$PATH"; export PATH

spt adapter add "$MANIFEST" >/dev/null 2>&1 || { echo "FAIL: spt adapter add"; exit 1; }

out=$(spt adapter digest-proof claude-spt --sample "$SAMPLE" 2>&1)
case "$out" in
  *DIGEST_PROOF_OK*)
    echo "ok  digest-proof: DIGEST_PROOF_OK"
    printf '%s\n' "$out" | grep -E 'parsed|dropped' | sed 's/^/    /'
    echo "DIGEST-PROOF-INT OK"; exit 0 ;;
  *"no value for substitution key {session_id}"*)
    echo "SKIP: blocked on F-004 — digest-proof --sample does not yet fill {session_id} (doyle's spt-core fix pending)."
    echo "      extractor proven via cargo tests + source-only Variant A (DIGEST_PROOF_OK). Flip on the carrying release."
    exit 0 ;;
  *)
    echo "FAIL: unexpected digest-proof output:"; printf '%s\n' "$out"; exit 1 ;;
esac


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

#!/bin/sh
# Integration proof: `spt adapter digest-proof` runs the claude-spt [digest] extractor and renders a
# non-empty digest from a real CC-shaped sample (ADR-0019 — the M10 acceptance criterion carried
# forward for spt-claude-code). [int->REQ-DIST-DIGEST-EXTRACTOR]
#
# Gated behind SPTC_ACCEPTANCE=1 (mutates the node-local adapter registry: add + soft-remove) and a
# present spt>=0.7 + a built extractor. Idempotent. Run: SPTC_ACCEPTANCE=1 sh ci/digest/digest-proof-int.sh
#
# KNOWN-BLOCKED (F-004, doyle-confirmed spt-core impl bug, fix in progress): the production-correct
# extractor command references {session_id} (CC's cwd-slug forces projects-root + a --session locate
# the extractor resolves itself). `digest-proof --sample` passes an EMPTY substitution-key map
# (cli.rs:5135) vs the runtime daemon which fills {id}+{session_id} — so it hard-fails {session_id}.
# Until doyle's fix lands (digest-proof will fill the runtime keys), this SKIPs on that exact error.
# The extractor itself is proven: cargo tests (ci/digest/build.sh) + a source-only digest-proof run
# returned DIGEST_PROOF_OK (5 parsed / 0 dropped, correct render — F-004 "Variant A").
set -u
ROOT=$(CDPATH= cd "$(dirname "$0")/../.." && pwd)
MANIFEST="$ROOT/adapter/claude-spt.toml"
SAMPLE="$ROOT/ci/digest/sample.jsonl"
RELDIR="$ROOT/tools/claude-spt-digest/target/release"

[ "${SPTC_ACCEPTANCE:-0}" = "1" ] || { echo "SKIP: set SPTC_ACCEPTANCE=1 to run (mutates the adapter registry)"; exit 0; }
command -v spt >/dev/null 2>&1 || { echo "SKIP: no spt on PATH"; exit 0; }
[ -x "$RELDIR/claude-spt-digest" ] || [ -x "$RELDIR/claude-spt-digest.exe" ] || { echo "SKIP: extractor not built (run sh ci/digest/build.sh)"; exit 0; }

trap 'spt adapter remove claude-spt >/dev/null 2>&1 || true' EXIT INT TERM
# Make the manifest's bare `claude-spt-digest` resolvable (the release dir holds the built binary).
PATH="$RELDIR:$PATH"; export PATH

spt adapter add "$MANIFEST" >/dev/null 2>&1 || { echo "FAIL: spt adapter add"; exit 1; }

out=$(spt adapter digest-proof claude-spt --sample "$SAMPLE" 2>&1)
case "$out" in
  *DIGEST_PROOF_OK*)
    echo "ok  digest-proof: DIGEST_PROOF_OK"
    printf '%s\n' "$out" | grep -E 'parsed|dropped' | sed 's/^/    /'
    echo "DIGEST-PROOF-INT OK"; exit 0 ;;
  *"no value for substitution key {session_id}"*)
    echo "SKIP: blocked on F-004 — digest-proof --sample does not yet fill {session_id} (doyle's spt-core fix pending)."
    echo "      extractor proven via cargo tests + source-only Variant A (DIGEST_PROOF_OK). Flip on the carrying release."
    exit 0 ;;
  *)
    echo "FAIL: unexpected digest-proof output:"; printf '%s\n' "$out"; exit 1 ;;
esac


Wiring the build gate into run-gates.sh:


Edit

gate "traceable-reqs check (requirement coverage)"

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 "traceable-reqs check (requirement coverage)"


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


28	else
29	  echo "SKIP: traceable-reqs not on PATH (install per docs/TRACEABILITY.md)"
30	fi
31
32	gate "skeleton-validate (cplugs installability)"
33	if [ -x "$ROOT/ci/publish/validate-skeleton.sh" ] || [ -f "$ROOT/ci/publish/validate-skeleton.sh" ]; then
34	  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
35	else

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


22	  if sh "$t" >/dev/null 2>&1; then printf 'ok  %s\n' "$t"; else fail "unit: $t"; sh "$t" || true; fi
23	done
24
25	gate "traceable-reqs check (requirement coverage)"
26	if command -v traceable-reqs >/dev/null 2>&1; then
27	  if traceable-reqs check >/dev/null 2>&1; then echo "ok  coverage green"; else fail "traceable-reqs check"; traceable-reqs check || true; fi

  if sh "$t" >/dev/null 2>&1; then printf 'ok  %s\n' "$t"; else fail "unit: $t"; sh "$t" || true; fi
done

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


done

gate "traceable-reqs check (requirement coverage)"
if command -v traceable-reqs >/dev/null 2>&1; then

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 "traceable-reqs check (requirement coverage)"
if command -v traceable-reqs >/dev/null 2>&1; then


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

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 "traceable-reqs check (requirement coverage)"
if command -v traceable-reqs >/dev/null 2>&1; then

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

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

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

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

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

+gate "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

+  echo "SKIP: no digest extractor build yet"


Adding the REQ. Inserting after REQ-DIST-SHORTCUT-BASENAME:


84	required_stages = ["doc", "impl", "unit"]  # doc: ADR-0001 (cc launcher brand, decoupled from the s/sptc/spt/ plugin-name succession). impl: the cc-launcher BRINGUP machinery — adapter [session.self] (spawns `claude`) + [env.SPT_ENDPOINT_ID] (broker injects the endpoint id) + the SessionStart bind branch (sptc_register_verb -> `api bind`, the spt-hosted self-register path). unit: tests/manifest-shortcut.sh (brand-value guard) + tests/hooks-parse.sh sptc_register_verb (bind/seed/boundary branch). int (real `spt endpoint run` picker emits cc-<id> + real-claude bringup-bind) deferred — the shortcut-emitting picker "lands in a later spt-core wave" (per `endpoint run --help`); the bringup-bind is now contract-unblocked (doyle confirmed 2026-06-15: bind is establishing/intrinsic-auth, --set-session-id only, no token) but still needs a real-claude acceptance spawn (heavier tier). adapter add accepting the [session.self]/[env] blocks is covered by registration-int.sh step 4d (REQ-DIST-MANIFEST-SCHEMA int).
85
86	[[requirements]]

required_stages = ["doc", "impl", "unit"]  # doc: ADR-0001 (cc launcher brand, decoupled from the s/sptc/spt/ plugin-name succession). impl: the cc-launcher BRINGUP machinery — adapter [session.self] (spawns `claude`) + [env.SPT_ENDPOINT_ID] (broker injects the endpoint id) + the SessionStart bind branch (sptc_register_verb -> `api bind`, the spt-hosted self-register path). unit: tests/manifest-shortcut.sh (brand-value guard) + tests/hooks-parse.sh sptc_register_verb (bind/seed/boundary branch). int (real `spt endpoint run` picker emits cc-<id> + real-claude bringup-bind) deferred — the shortcut-emitting picker "lands in a later spt-core wave" (per `endpoint run --help`); the bringup-bind is now contract-unblocked (doyle confirmed 2026-06-15: bind is establishing/intrinsic-auth, --set-session-id only, no token) but still needs a real-claude acceptance spawn (heavier tier). adapter add accepting the [session.self]/[env] blocks is covered by registration-int.sh step 4d (REQ-DIST-MANIFEST-SCHEMA int).

[[requirements]]


adapter add accepting the [session.self]/[env] blocks is covered by registration-int.sh step 4d (REQ-DIST-MANIFEST-SCHEMA int).

[[requirements]]
id = "REQ-UPS-INJECTION"

adapter add accepting the [session.self]/[env] blocks is covered by registration-int.sh step 4d (REQ-DIST-MANIFEST-SCHEMA int).

[[requirements]]
id = "REQ-DIST-DIGEST-EXTRACTOR"
title = "The claude-spt [digest] extractor (claude-spt-digest) maps Claude Code's JSONL transcript to the published digest-record NDJSON contract (role∈{input,agent,tool}; text/tool/ts), emitting RAW records for spt-core's renderer (ADR-0019)"
required_stages = ["impl", "unit"]  # impl: tools/claude-spt-digest (Rust — CC JSONL -> digest NDJSON; per-session locate of <slug>/<session_id>.jsonl under the projects root via the {session_id} the command receives; native UTF-8 stdout; defensive ~ expansion) + ci/digest/build.sh gate. unit: the crate's cargo tests (9: input/agent/tool mapping, arg-key preference, thinking+tool_result+noise+bad-json drops, UTF-8 round-trip, contract invariants, tilde expansion) run by ci/digest/build.sh. doc deferred (the mapping is documented in the module + manifest [digest] comment; a docs-site section lands with the doc slice). int deferred behind F-004: `spt adapter digest-proof --sample` passes an empty substitution-key map (doyle-confirmed spt-core impl bug, cli.rs:5135; runtime fills {id}+{session_id}) so it false-fails the {session_id}-templated command; ci/digest/digest-proof-int.sh skips on that exact error. Extractor proven meanwhile: cargo tests + a source-only digest-proof run returned DIGEST_PROOF_OK (5 parsed / 0 dropped, correct render — F-004 Variant A). Flips green on doyle's carrying release.

[[requirements]]
id = "REQ-UPS-INJECTION"