1use std::io::Read;
5use std::io::Write;
6use std::path::Path;
7
8pub 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
24pub 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 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
71fn 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
148pub 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
166fn 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
200pub 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
238const CHECK_UPDATE_SH: &str = include_str!("../embed/plugins/claude/hooks/scc/check-update.sh");
243const OPENCODE_UPDATE_TS: &str = include_str!("../embed/plugins/opencode/scc-update.ts");
245
246pub 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 let out = crate::agents_md::replace_scc_section(&existing, §ion);
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
274fn 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, 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
296pub 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
325fn 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, 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 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]
359fn 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]
373fn 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]
388fn 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]
402fn 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 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 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 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}