Skip to main content

scc_cli/
compress.rs

1//! Pluggable constrained compression (docs/CONTEXT_COMPILER.md §7, SCC-072)
2//! and cross-harness capsule export (docs/G7).
3
4use std::io::Read;
5use std::io::Write;
6use std::path::Path;
7
8/// `scc context compress <goal> [--cmd <cmd>] [--budget N] [--json]`
9///
10/// The pack builder already applies the structural compression ladder
11/// (dedup, component collapse, evidence compression, priority-preserving
12/// truncation). `--cmd` additionally pipes the markdown through an external
13/// summarizer; its output is treated as UNTRUSTED inference and labeled.
14// trace:v1 id=impl.crates-scc-cli-src-compress.cmd-context-compress-json
15pub fn cmd_context_compress_json(
16    root: &Path,
17    goal: &str,
18    cmd: Option<String>,
19    budget: Option<usize>,
20) -> crate::Result<String> {
21    cmd_context_compress_json_claims(root, goal, cmd, budget, false)
22}
23
24/// Constrained generation seam (SCC-072, docs §71): with `claims=true` the
25/// external summarizer receives structured pack JSON (goal, content,
26/// entity_ids, evidence_summary) and MUST emit typed claims referencing
27/// known evidence; claims without valid evidence references are rejected —
28/// the LLM may label evidence, it may not invent it.
29// trace:v1 id=impl.crates-scc-cli-src-compress.cmd-context-compress-json-claims
30pub fn cmd_context_compress_json_claims(
31    root: &Path,
32    goal: &str,
33    cmd: Option<String>,
34    budget: Option<usize>,
35    claims: bool,
36) -> crate::Result<String> {
37    // delta is dynamic surface text, not compressible pack content). The
38    // pure builder is used — NOT `build_task_context` — so compression
39    // never builds a Surface delta or records its ids into the
40    // ContextLedger (a pack-only caller discarding the delta must not mark
41    // APIs the agent never saw as already visible).
42    let mut pack: scc_context::ContextPack =
43        crate::commands::build_enriched_task_pack(root, goal, &[], &[], budget, false)?;
44    if let Some(command) = cmd {
45        if claims {
46            return compress_with_claims(&command, &mut pack);
47        }
48        let input = pack.content.clone();
49        let out = run_external(&command, &input)?;
50        let max = input.len().saturating_mul(2).max(4096);
51        if out.len() > max {
52            return Err(crate::CliError::Other(format!(
53                "external summarizer produced {} bytes (limit {max})",
54                out.len()
55            )));
56        }
57        let cmd_hint: String = command.chars().take(80).collect();
58        pack.content = format!(
59            "<!-- compressed by external summarizer: {cmd_hint} — treat as INFERRED, not verified facts -->\n\n{out}"
60        );
61        pack.kind = "task-compressed".into();
62        pack.compression_policy = Some(serde_json::json!({
63            "external": cmd_hint,
64            "note": "output is untrusted inference; repository facts remain authoritative"
65        }));
66    }
67
68    Ok(serde_json::to_string_pretty(&pack)?)
69}
70
71// trace:v1 id=impl.crates-scc-cli-src-compress.compress-with-claims
72fn compress_with_claims(command: &str, pack: &mut scc_context::ContextPack) -> crate::Result<String> {
73    let input = serde_json::json!({
74        "goal": pack.content.split('\n').next().unwrap_or("").to_string(),
75        "content": pack.content,
76        "entity_ids": pack.entity_ids,
77        "evidence_summary": pack.evidence_summary,
78    });
79    let input_str = input.to_string();
80    let out = run_external(command, &input_str)?;
81    let max = input_str.len().saturating_mul(2).max(4096);
82    if out.len() > max {
83        return Err(crate::CliError::Other(format!(
84            "external summarizer produced {} bytes (limit {max})",
85            out.len()
86        )));
87    }
88    let parsed: serde_json::Value = serde_json::from_str(&out)
89        .map_err(|e| crate::CliError::Other(format!("summarizer output is not JSON: {e}")))?;
90    let claims_arr = parsed
91        .get("claims")
92        .or_else(|| parsed.as_array().map(|_| &parsed))
93        .and_then(|v| v.as_array())
94        .ok_or_else(|| crate::CliError::Other("summarizer must emit {\"claims\": [...]} or an array".into()))?;
95
96    let known: std::collections::BTreeSet<String> = pack
97        .entity_ids
98        .iter()
99        .cloned()
100        .chain(pack.evidence_summary.keys().cloned())
101        .collect();
102    let mut rendered = String::new();
103    let mut rejected: Vec<String> = Vec::new();
104    for (i, c) in claims_arr.iter().enumerate() {
105        let claim = c.get("claim").and_then(|v| v.as_str()).unwrap_or("").to_string();
106        let ev: Vec<String> = c
107            .get("evidence")
108            .and_then(|v| v.as_array())
109            .map(|a| {
110                a.iter()
111                    .filter_map(|x| x.as_str().map(|s| s.to_string()))
112                    .collect()
113            })
114            .unwrap_or_default();
115        if claim.is_empty() {
116            rejected.push(format!("claim #{i}: empty claim text"));
117            continue;
118        }
119        let bad: Vec<&String> = ev.iter().filter(|e| !known.contains(*e)).collect();
120        if !bad.is_empty() {
121            rejected.push(format!("claim #{i} ({claim:?}): unknown evidence {bad:?}"));
122            continue;
123        }
124        let ev_disp = if ev.is_empty() {
125            "none (unverified)".to_string()
126        } else {
127            ev.join(", ")
128        };
129        rendered.push_str(&format!("- {claim} [evidence: {ev_disp}]\n"));
130    }
131    if !rejected.is_empty() {
132        return Err(crate::CliError::Other(format!(
133            "{} claim(s) rejected — the summarizer may label evidence but not invent it:\n{}",
134            rejected.len(),
135            rejected.join("\n")
136        )));
137    }
138    pack.content = format!(
139        "<!-- claims compressed by external summarizer: {} — INFERRED, not verified facts -->\n\n{}\n## EXTERNAL CLAIMS (INFERRED)\n{}",
140        command.chars().take(80).collect::<String>(),
141        pack.content,
142        rendered
143    );
144    pack.kind = "task-claims".into();
145    Ok(serde_json::to_string_pretty(pack)?)
146}
147
148// trace:v1 id=impl.crates-scc-cli-src-compress.cmd-context-compress
149pub fn cmd_context_compress(
150    root: &Path,
151    goal: &str,
152    cmd: Option<String>,
153    budget: Option<usize>,
154    json: bool,
155) -> crate::Result<()> {
156    let out = cmd_context_compress_json(root, goal, cmd, budget)?;
157    if json {
158        println!("{out}");
159    } else {
160        let pack: scc_context::ContextPack = serde_json::from_str(&out)?;
161        print!("{}", pack.content);
162    }
163    Ok(())
164}
165
166// trace:v1 id=impl.crates-scc-cli-src-compress.run-external
167fn run_external(command: &str, input: &str) -> crate::Result<String> {
168    let mut child = std::process::Command::new("sh")
169        .arg("-c")
170        .arg(command)
171        .stdin(std::process::Stdio::piped())
172        .stdout(std::process::Stdio::piped())
173        .stderr(std::process::Stdio::null())
174        .spawn()
175        .map_err(|e| crate::CliError::Other(format!("cannot spawn summarizer: {e}")))?;
176    child
177        .stdin
178        .take()
179        .ok_or_else(|| crate::CliError::Other("summarizer stdin unavailable".into()))?
180        .write_all(input.as_bytes())
181        .map_err(|e| crate::CliError::Other(format!("write to summarizer: {e}")))?;
182    let mut out = String::new();
183    child
184        .stdout
185        .take()
186        .ok_or_else(|| crate::CliError::Other("summarizer stdout unavailable".into()))?
187        .read_to_string(&mut out)
188        .map_err(|e| crate::CliError::Other(format!("read from summarizer: {e}")))?;
189    let status = child
190        .wait()
191        .map_err(|e| crate::CliError::Other(format!("summarizer wait: {e}")))?;
192    if !status.success() {
193        return Err(crate::CliError::Other(format!(
194            "summarizer exited with {status}"
195        )));
196    }
197    Ok(out)
198}
199
200/// `scc export capsule.md` — portable startup capsule for any harness
201/// (Claude Code, Codex, Hermes, OpenCode...).
202///
203/// A durable committed file cannot BE the live 20k-token startup artifact,
204/// so the capsule is a deliberately defined derivative: the compact
205/// overview PLUS the bounded repository skeleton. Every SCC-supported
206/// agent therefore receives the physical layout before its first
207/// reasoning turn; the skeleton also tells it to run `scc context
208/// startup` for the fused live architecture.
209// trace:v1 id=impl.crates-scc-cli-src-compress.capsule-markdown
210pub fn capsule_markdown(root: &Path) -> crate::Result<String> {
211    let store = crate::open_store(root)?;
212    let config = crate::load_config(root)?;
213    let stale = crate::stale_paths(&store)?;
214    let comp = crate::compiler(&store, &config, stale)?;
215    let overview = comp.ctx().system_overview();
216    let revision = overview.repository_revision.clone();
217    let repo = store.repository();
218    let paths: Vec<String> = store
219        .all_files()
220        .unwrap_or_default()
221        .into_iter()
222        .map(|(p, _, _, _, _)| p)
223        .collect();
224    let skeleton = scc_context::skeleton::build_skeleton(
225        &paths,
226        scc_context::skeleton::skeleton_budget(config.context.startup_tokens),
227    );
228    Ok(format!(
229        "<!-- SCC-CAPSULE v1 repo={} revision={} generated={} -->\n# SYSTEM CAPSULE\n\n{}\n## REPOSITORY SKELETON\n\n{}\n\n(Physical layout above; run `scc context startup` for the fused live architecture.)\n",
230        repo.id,
231        revision,
232        scc_core::now_rfc3339(),
233        overview.content,
234        skeleton.text
235    ))
236}
237
238// Shared SessionStart update checker (same file the Claude installer bakes
239// in): cache-only startup path, detached background refresh, systemMessage
240// JSON solely when an update is due.
241// trace:exempt reason=internal-detail
242const CHECK_UPDATE_SH: &str = include_str!("../embed/plugins/claude/hooks/scc/check-update.sh");
243// trace:exempt reason=internal-detail
244const OPENCODE_UPDATE_TS: &str = include_str!("../embed/plugins/opencode/scc-update.ts");
245
246/// `scc setup codex` — write AGENTS.md with the capsule + usage rules
247/// (docs/API_AND_INTEGRATIONS.md §5 for the Codex harness).
248// trace:v1 id=impl.crates-scc-cli-src-compress.cmd-setup-codex
249pub fn cmd_setup_codex(root: &Path) -> crate::Result<()> {
250    let capsule = capsule_markdown(root)?;
251    let path = root.join("AGENTS.md");
252    let existing = std::fs::read_to_string(&path).unwrap_or_default();
253    let section = format!(
254        "<!-- SCC-SECTION -->\n{capsule}\n## SCC usage rules\n\
255         - The repository is indexed by SCC. For a task, run: `scc context task \"<goal>\"` and work within it.\n\
256         - For the fused startup architecture (Atlas + Surface + coverage) at session start, run `scc context startup` — `scc atlas` is only its Level-0 Atlas component.\n\
257         - `scc verify` reports freshness and drift — do not trust stale facts; re-index with `scc index` first.\n\
258         - Authority ordering: source/runtime > SCC System IR > checkpoint > Hindsight > model assumption.\n\
259         - Drift and invariants: `scc drift`, `scc ci check`, and `scc impact <files>` before cross-layer edits.\n\
260         <!-- /SCC-SECTION -->\n"
261    );
262    // Marker-aware replacement: keep user text BEFORE the opening marker
263    // AND everything AFTER the closing marker (the previous rewrite
264    // dropped later user edits).
265    let out = crate::agents_md::replace_scc_section(&existing, &section);
266    std::fs::write(&path, out)?;
267    println!("wrote {}", path.display());
268    println!("AGENTS.md now carries the system capsule; normal Codex sessions start with system understanding.");
269
270    install_codex_update_check(root)?;
271    Ok(())
272}
273
274// Update reminder: Codex hooks live in user scope (~/.codex/hooks.json),
275// which setup must never edit — install the checker into the repo and
276// print the exact entry for the user to add under "SessionStart".
277// trace:v1 id=impl.crates-scc-cli-src-compress.cmd-setup-codex-update-check work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
278fn install_codex_update_check(root: &Path) -> crate::Result<()> {
279    let hook_path = root.join(".codex").join("scc-check-update.sh");
280    std::fs::create_dir_all(hook_path.parent().unwrap())?;
281    std::fs::write(&hook_path, crate::plugin_omp::strip_authoring_markers(CHECK_UPDATE_SH))?;
282    #[cfg(unix)]
283    {
284        use std::os::unix::fs::PermissionsExt;
285        std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755))?;
286    }
287    println!("wrote {}", hook_path.display());
288    println!();
289    println!("To enable the SCC update reminder, add this entry to the");
290    println!("\"SessionStart\" array in ~/.codex/hooks.json:");
291    println!("  {{\"matcher\": \"\", \"hooks\": [{{\"type\": \"command\",");
292    println!("   \"command\": \"{}\", \"timeout\": 10}}]}}", hook_path.display());
293    Ok(())
294}
295
296/// `scc setup opencode` (M10): AGENTS.md already carries the capsule
297/// (Codex/OpenCode/Hermes all read AGENTS.md); additionally write
298/// .opencode/opencode.json wiring the SCC MCP server so OpenCode sessions
299/// get the six semantic tools.
300// trace:v1 id=impl.crates-scc-cli-src-compress.cmd-setup-opencode
301pub fn cmd_setup_opencode(root: &Path) -> crate::Result<()> {
302    if !root.join("AGENTS.md").exists() {
303        cmd_setup_codex(root)?;
304    }
305    let dir = root.join(".opencode");
306    std::fs::create_dir_all(&dir)?;
307    let config = dir.join("opencode.json");
308    let existing = std::fs::read_to_string(&config).unwrap_or_else(|_| "{}".to_string());
309    let mut v: serde_json::Value = serde_json::from_str(&existing).unwrap_or(serde_json::json!({}));
310    let mcp = v.get_mut("mcp").and_then(|m| m.as_object_mut()).cloned().unwrap_or_default();
311    let mut mcp = mcp;
312    mcp.insert(
313        "scc".to_string(),
314        serde_json::json!({"type": "stdio", "command": "scc", "args": ["mcp"]}),
315    );
316    v["mcp"] = serde_json::Value::Object(mcp);
317    std::fs::write(&config, serde_json::to_string_pretty(&v)?)?;
318    println!("wrote {}", config.display());
319    install_opencode_update_plugin(root)?;
320    println!("OpenCode sessions will auto-connect the SCC MCP server (six semantic tools).");
321    println!("Hermes and other harnesses read AGENTS.md (system capsule) — no per-harness config needed.");
322    Ok(())
323}
324
325// Update reminder project plugin: `session.created` shows a TUI toast when
326// a newer release is due (cache-only path, detached refresh). Installed
327// into the repo so no user-scope change is needed.
328// trace:v1 id=impl.crates-scc-cli-src-compress.cmd-setup-opencode-update-plugin work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
329fn install_opencode_update_plugin(root: &Path) -> crate::Result<()> {
330    let path = root.join(".opencode").join("plugins").join("scc-update.ts");
331    std::fs::create_dir_all(path.parent().unwrap())?;
332    std::fs::write(&path, crate::plugin_omp::strip_authoring_markers(OPENCODE_UPDATE_TS))?;
333    println!("wrote {}", path.display());
334    println!("OpenCode shows an update toast on session start when a newer SCC release exists.");
335    Ok(())
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    // trace:exempt reason=unit-test
344    fn opencode_setup_installs_update_plugin() {
345        let dir = tempfile::TempDir::new().unwrap();
346        let root = dir.path().join("repo");
347        std::fs::create_dir_all(&root).unwrap();
348        cmd_setup_opencode(&root).unwrap();
349        let path = root.join(".opencode").join("plugins").join("scc-update.ts");
350        assert!(path.exists(), "opencode update plugin installed");
351        let body = std::fs::read_to_string(&path).unwrap();
352        assert!(body.contains("session.created"), "plugin hooks session creation");
353        assert!(body.contains("showToast"), "plugin notifies via TUI toast");
354        assert!(body.contains("start_new_session") || body.contains("refreshInBackground"),
355            "plugin refreshes cache off the startup path");
356    }
357
358    #[test]
359// trace:v1 id=impl.crates-scc-cli-src-compress.external-summarizer-runs-and-is-labeled
360    fn external_summarizer_runs_and_is_labeled() {
361        let dir = tempfile::TempDir::new().unwrap();
362        let root = dir.path().join("repo");
363        std::fs::create_dir_all(&root).unwrap();
364        std::fs::write(root.join("a.py"), "def helper():\n    pass\n").unwrap();
365        crate::commands::cmd_index(&root, true).unwrap();
366        let out = cmd_context_compress_json(&root, "helper", Some("tr -d '#'".into()), None).unwrap();
367        assert!(out.contains("compressed by external summarizer"), "{out}");
368        assert!(out.contains("INFERRED"), "{out}");
369        assert!(out.contains("TASK"), "{out}");
370    }
371
372    #[test]
373// trace:v1 id=impl.crates-scc-cli-src-compress.claims-mode-rejects-invented-evidence
374    fn claims_mode_rejects_invented_evidence() {
375        let dir = tempfile::TempDir::new().unwrap();
376        let root = dir.path().join("repo");
377        std::fs::create_dir_all(&root).unwrap();
378        std::fs::write(root.join("a.py"), "def helper():\n    return 1\n").unwrap();
379        crate::commands::cmd_index(&root, true).unwrap();
380        let fake = "python3 -c 'import json,sys; print(json.dumps({\"claims\":[{\"claim\":\"x\",\"evidence\":[\"evidence:nope\"]}]}))'";
381        let err = cmd_context_compress_json_claims(&root, "helper", Some(fake.into()), None, true)
382            .expect_err("invented evidence must be rejected");
383        assert!(err.to_string().contains("rejected"), "{err}");
384        assert!(err.to_string().contains("evidence:nope"), "{err}");
385    }
386
387    #[test]
388// trace:v1 id=impl.crates-scc-cli-src-compress.capsule-export-has-header
389    fn capsule_export_has_header() {
390        let dir = tempfile::TempDir::new().unwrap();
391        let root = dir.path().join("repo");
392        std::fs::create_dir_all(&root).unwrap();
393        std::fs::write(root.join("a.py"), "def helper():\n    pass\n").unwrap();
394        crate::commands::cmd_index(&root, true).unwrap();
395        let md = capsule_markdown(&root).unwrap();
396        assert!(md.starts_with("<!-- SCC-CAPSULE v1"), "{md}");
397        assert!(md.contains("# SYSTEM CAPSULE"), "{md}");
398        assert!(md.contains("IDENTITY"), "{md}");
399    }
400
401    #[test]
402// trace:v1 id=impl.crates-scc-cli-src-compress.codex-setup-idempotent-with-user-content
403    fn codex_setup_idempotent_with_user_content() {
404        let dir = tempfile::TempDir::new().unwrap();
405        let root = dir.path().join("repo");
406        std::fs::create_dir_all(&root).unwrap();
407        std::fs::write(root.join("a.py"), "def helper():\n    pass\n").unwrap();
408        crate::commands::cmd_index(&root, true).unwrap();
409        std::fs::write(root.join("AGENTS.md"), "# user content\nkeep me\n").unwrap();
410        cmd_setup_codex(&root).unwrap();
411        cmd_setup_codex(&root).unwrap();
412        // Update reminder checker installs alongside the capsule.
413        let hook = root.join(".codex").join("scc-check-update.sh");
414        assert!(hook.exists(), "codex checker script installed");
415        let body = std::fs::read_to_string(&hook).unwrap();
416        assert!(body.contains("systemMessage"), "checker emits systemMessage JSON");
417        assert!(body.contains("start_new_session"), "checker refresh detaches");
418        #[cfg(unix)]
419        {
420            use std::os::unix::fs::PermissionsExt;
421            assert_eq!(
422                std::fs::metadata(&hook).unwrap().permissions().mode() & 0o111,
423                0o111,
424                "checker script executable"
425            );
426        }
427        let text = std::fs::read_to_string(root.join("AGENTS.md")).unwrap();
428        assert!(text.contains("keep me"), "user content preserved");
429        assert!(text.contains("SCC-SECTION"), "{text}");
430        assert_eq!(text.matches("SCC-SECTION").count(), 2, "one SCC section only");
431        // the generated AGENTS.md must point at `scc context startup` as
432        // the fused startup architecture source and carry the authority
433        // ordering line
434        assert!(text.contains("scc context startup"), "fused startup reference: {text}");
435        assert!(
436            text.contains("source/runtime > SCC System IR > checkpoint > Hindsight > model assumption"),
437            "authority ordering: {text}"
438        );
439    }
440
441    #[test]
442    // trace:v1 id=test.scc-cli-compress.codex-preserves-after-marker work=WORK-SCC-001 verifies=REQ-SCC-API,REQ-implement-p0-omp-integration-correctness-and-writable-benchmark-scient
443    fn codex_setup_preserves_text_after_closing_marker() {
444        let dir = tempfile::TempDir::new().unwrap();
445        let root = dir.path().join("repo");
446        std::fs::create_dir_all(&root).unwrap();
447        std::fs::write(root.join("a.py"), "def helper():\n    pass\n").unwrap();
448        crate::commands::cmd_index(&root, true).unwrap();
449        std::fs::write(
450            root.join("AGENTS.md"),
451            "# user before\n<!-- SCC-SECTION -->\nold\n<!-- /SCC-SECTION -->\n# user after — keep me too\n",
452        )
453        .unwrap();
454        cmd_setup_codex(&root).unwrap();
455        let text = std::fs::read_to_string(root.join("AGENTS.md")).unwrap();
456        assert!(text.contains("user before"), "{text}");
457        assert!(
458            text.contains("user after — keep me too"),
459            "text after the closing marker must survive: {text}"
460        );
461        assert!(!text.contains("\nold\n"), "{text}");
462    }
463}