Skip to main content

scc_cli/
plugin_omp.rs

1//! Oh My Pi (OMP) integration (docs/API_AND_INTEGRATIONS.md §5, P0).
2//!
3//! `scc setup omp` installs a native project-scoped integration with NO
4//! manual post-config:
5//!   1. `.omp/extensions/scc/index.ts` + `package.json` — ONE native
6//!      extension module that registers ALL ordering-dependent SCC
7//!      lifecycle behavior (`session_start`, `before_agent_start`,
8//!      `tool_result`, `session_before_compact`, `session.compacting`).
9//!   2. `.omp/mcp.json` — merged (preserving existing servers) wiring the
10//!      SCC MCP server (`scc mcp`) for manual semantic drill-down. The
11//!      command honors `SCC_BIN` when set at setup time.
12//!   3. `.omp/skills/scc-system-context/SKILL.md` — on-demand workflow
13//!      guidance (no hard rules live only in the skill).
14//!   4. AGENTS.md — SCC DURABLE RULES ONLY (never generated Atlas/Surface
15//!      facts). Native `.omp/AGENTS.md` has higher provider priority than
16//!      the standalone root `AGENTS.md`. The installer patches the
17//!      winning existing file and never silently creates a shadowing
18//!      `.omp/AGENTS.md` when only the root file exists.
19//!
20//! The extension uses `pi.exec(process.env.SCC_BIN ?? "scc", [...])`
21//! argument arrays (never shell interpolation), preserves arg boundaries,
22//! captures exit/stdout/stderr, catches errors, and never crashes the OMP
23//! session.
24
25use std::path::Path;
26
27use crate::agents_md::{
28    replace_scc_section_in, resolve_omp_agents_path, AgentsTarget,
29    SCC_OMP_SECTION_CLOSE, SCC_OMP_SECTION_OPEN,
30};
31
32// The native extension entry: ONE module registers every ordering-dependent
33// SCC lifecycle behavior (OMP does not promise filename/module ordering).
34const EXTENSION_TS: &str = include_str!("../embed/plugins/omp/scc/index.ts");
35const EXTENSION_PACKAGE: &str = include_str!("../embed/plugins/omp/scc/package.json");
36const SKILL_MD: &str = include_str!("../embed/plugins/omp/scc/skills/scc-system-context/SKILL.md");
37const UPDATE_CHECK_TS: &str = include_str!("../embed/plugins/omp/scc/update-check.ts");
38
39// trace:exempt reason=const-data (behavior boundary is write_agents_rules)
40const AGENTS_RULES: &str = "<!-- SCC-OMP-SECTION -->\n\
41# SCC (System Context Compiler)\n\
42This repository is indexed by SCC. Durable rules:\n\
43- The native SCC extension injects the startup architecture once per session\n\
44  and a task-specific context pack per prompt. Work within the injected task\n\
45  context; it is the authoritative system slice for the current goal.\n\
46- For the fused startup architecture (Atlas + Surface + coverage),\n\
47  `scc context startup` is the canonical command; `scc atlas` is only its\n\
48  Level-0 Atlas component.\n\
49- Authority ordering: source/runtime > SCC System IR > checkpoint > Hindsight\n\
50  > model assumption.\n\
51- Drift and invariants: `scc drift`, `scc ci check`, and `scc impact <files>`\n\
52  before cross-layer edits.\n\
53<!-- /SCC-OMP-SECTION -->\n";
54
55/// `scc setup omp` — install the native OMP integration.
56// trace:v1 id=impl.crates-scc-cli-src-plugin-omp.cmd-setup-omp work=WORK-SCC-001 satisfies=REQ-SCC-API
57pub fn cmd_setup_omp(root: &Path) -> crate::Result<()> {
58    let omp_dir = root.join(".omp");
59    // 1. Native extension package.
60    let ext_dir = omp_dir.join("extensions/scc");
61    std::fs::create_dir_all(&ext_dir)?;
62    std::fs::write(ext_dir.join("index.ts"), installable_extension_ts())?;
63    std::fs::write(ext_dir.join("update-check.ts"), installable_aux_ts(UPDATE_CHECK_TS))?;
64    std::fs::write(ext_dir.join("package.json"), extension_package())?;
65    println!("wrote {}", ext_dir.join("index.ts").display());
66
67    // 2. Merge `.omp/mcp.json`, preserving existing servers.
68    merge_mcp_json(&omp_dir)?;
69
70    // 3. Skill (on-demand workflow guidance).
71    let skill_dir = omp_dir.join("skills/scc-system-context");
72    std::fs::create_dir_all(&skill_dir)?;
73    std::fs::write(skill_dir.join("SKILL.md"), skill_md())?;
74    println!("wrote {}", skill_dir.join("SKILL.md").display());
75
76    // 4. Durable rules: patch the OMP-winning instruction file; never
77    // silently introduce a shadowing `.omp/AGENTS.md` when only the
78    // root AGENTS.md exists.
79    let agents_path = write_agents_rules(root)?;
80
81    println!();
82    println!("OMP integration installed:");
83    println!("  extension  -> {}", ext_dir.join("index.ts").display());
84    println!("  mcp.json   -> {}", omp_dir.join("mcp.json").display());
85    println!("  skill      -> {}", skill_dir.join("SKILL.md").display());
86    println!("  AGENTS.md  -> {}", agents_path.display());
87    println!();
88    println!("Restart OMP, then run `/extensions` to verify the SCC extension");
89    println!("is loaded (`/extensions` is an inspector, not a reload).");
90    println!("Verify MCP with `/mcp reload` then `/mcp test scc`.");
91    println!(
92        "The `scc` binary must be on PATH, or set SCC_BIN (the extension reads process.env.SCC_BIN)."
93    );
94    Ok(())
95}
96
97/// The extension source with system_ir's authoring markers rewritten to
98/// exempt comments: the installed artifact runs in a user's repository,
99/// where this repo's work/requirement nodes do not exist (dangling TL002
100/// edges under TraceLayer otherwise).
101// trace:exempt reason=internal-helper
102fn installable_extension_ts() -> String {
103    strip_authoring_markers(EXTENSION_TS)
104}
105
106// The installed artifact runs in a user's repository, where this repo's
107// work/requirement nodes do not exist (dangling TL002 edges under
108// TraceLayer otherwise). Every authoring marker line becomes
109// an exempt comment; all other lines pass through byte-identical.
110// trace:exempt reason=internal-helper
111fn strip_authoring_markers(src: &str) -> String {
112    const EXEMPT: &str = "// trace:exempt reason=scc-installed-tooling (authoring marker from the SCC source repo removed at install)";
113    let mut out = String::with_capacity(src.len());
114    for line in src.split('\n') {
115        if line.trim_start().starts_with("// trace:v1 ") {
116            let indent = &line[..line.len() - line.trim_start().len()];
117            out.push_str(indent);
118            out.push_str(EXEMPT);
119        } else {
120            out.push_str(line);
121        }
122        out.push('\n');
123    }
124    if src.ends_with('\n') {
125        out.pop();
126    }
127    out
128}
129
130// trace:exempt reason=internal-helper
131fn installable_aux_ts(src: &str) -> String {
132    strip_authoring_markers(src)
133}
134
135// Upstream Pi resolves the same extension API under its own scope; the
136// extension body only uses pi.on/pi.exec/ctx.ui/ctx.cwd, which exist on
137// both sides, so a scope rewrite is the entire port.
138// trace:v1 id=impl.crates-scc-cli-src-plugin-omp.cmd-setup-pi work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
139pub fn cmd_setup_pi(root: &Path) -> crate::Result<()> {
140    let ext_dir = root.join(".pi").join("extensions").join("scc");
141    std::fs::create_dir_all(&ext_dir)?;
142    std::fs::write(ext_dir.join("index.ts"), installable_pi_ts(EXTENSION_TS))?;
143    std::fs::write(ext_dir.join("update-check.ts"), installable_aux_ts(UPDATE_CHECK_TS))?;
144    std::fs::write(ext_dir.join("package.json"), extension_package())?;
145    println!("wrote {}", ext_dir.join("index.ts").display());
146    println!();
147    println!("Pi integration installed (project-local .pi/extensions/scc).");
148    println!("Restart Pi; the extension hot-reloads with /reload.");
149    println!("The `scc` binary must be on PATH, or set SCC_BIN.");
150    Ok(())
151}
152
153// trace:exempt reason=internal-helper
154fn installable_pi_ts(src: &str) -> String {
155    strip_authoring_markers(src).replace(
156        "@oh-my-pi/pi-coding-agent",
157        "@mariozechner/pi-coding-agent",
158    )
159}
160
161// trace:exempt reason=internal-helper
162fn extension_package() -> &'static str {
163    EXTENSION_PACKAGE
164}
165
166// trace:exempt reason=internal-helper
167fn skill_md() -> &'static str {
168    SKILL_MD
169}
170
171// trace:exempt reason=internal-helper
172fn scc_bin() -> String {
173    if let Ok(v) = std::env::var("SCC_BIN") {
174        let v = v.trim().to_string();
175        if !v.is_empty() {
176            return v;
177        }
178    }
179    // Absolute path only when the current exe IS the scc binary (exact
180    // file-stem match — a test-harness or build-tool exe path must never
181    // leak into generated configs).
182    if let Ok(exe) = std::env::current_exe() {
183        if exe.file_stem().map(|n| n == "scc").unwrap_or(false) {
184            return exe.to_string_lossy().into_owned();
185        }
186    }
187    "scc".to_string()
188}
189
190/// Merge the SCC MCP server into `.omp/mcp.json`, preserving any existing
191/// servers. The format is the standard MCP config: `{ "mcpServers": {
192/// "<name>": { "command": ..., "args": [...] } } }`. Idempotent: if the
193/// `scc` server is already present, it is left unchanged unless SCC_BIN
194/// is set (then the command is updated to the known static path).
195// trace:v1 id=impl.crates-scc-cli-src-plugin-omp.merge-mcp-json work=WORK-SCC-001 satisfies=REQ-SCC-API
196fn merge_mcp_json(omp_dir: &Path) -> crate::Result<()> {
197    let path = omp_dir.join("mcp.json");
198    let mut v: serde_json::Value = if path.exists() {
199        let text = std::fs::read_to_string(&path)?;
200        serde_json::from_str(&text).unwrap_or(serde_json::json!({}))
201    } else {
202        serde_json::json!({})
203    };
204    let servers = v
205        .get_mut("mcpServers")
206        .and_then(|m| m.as_object_mut())
207        .cloned()
208        .unwrap_or_default();
209    let mut servers = servers;
210    let cmd = scc_bin();
211    match servers.get_mut("scc") {
212        Some(existing) => {
213            if std::env::var("SCC_BIN").map(|s| !s.trim().is_empty()).unwrap_or(false) {
214                existing["command"] = serde_json::Value::String(cmd);
215            }
216        }
217        None => {
218            servers.insert(
219                "scc".to_string(),
220                serde_json::json!({"command": cmd, "args": ["mcp"]}),
221            );
222        }
223    }
224    v["mcpServers"] = serde_json::Value::Object(servers);
225    std::fs::write(&path, serde_json::to_string_pretty(&v)?)?;
226    Ok(())
227}
228
229/// Write the durable SCC rules into the OMP-winning AGENTS.md, preserving
230/// user content on BOTH sides of the managed section (idempotent).
231// trace:v1 id=impl.crates-scc-cli-src-plugin-omp.write-agents-rules work=WORK-SCC-001 satisfies=REQ-SCC-API
232fn write_agents_rules(root: &Path) -> crate::Result<std::path::PathBuf> {
233    let resolution = resolve_omp_agents_path(root);
234    if resolution.both_exist {
235        eprintln!(
236            "warning: both .omp/AGENTS.md and AGENTS.md exist; OMP prefers .omp/AGENTS.md \
237             (higher-priority same-scope context) and will shadow the root file. \
238             Patching .omp/AGENTS.md. Remove one of the files if that precedence is unintended."
239        );
240    }
241    let path = match resolution.target {
242        AgentsTarget::Omp => root.join(".omp").join("AGENTS.md"),
243        AgentsTarget::Root => root.join("AGENTS.md"),
244    };
245    if let Some(parent) = path.parent() {
246        std::fs::create_dir_all(parent)?;
247    }
248    let existing = std::fs::read_to_string(&path).unwrap_or_default();
249    let out = replace_scc_section_in(
250        &existing,
251        AGENTS_RULES,
252        SCC_OMP_SECTION_OPEN,
253        SCC_OMP_SECTION_CLOSE,
254    );
255    std::fs::write(&path, out)?;
256    Ok(path)
257}
258
259#[cfg(test)]
260mod tests {
261    #[test]
262    // trace:exempt reason=unit-test
263    fn pi_setup_installs_retargeted_extension() {
264        let dir = tempfile::TempDir::new().unwrap();
265        let root = dir.path().join("repo");
266        std::fs::create_dir_all(&root).unwrap();
267        cmd_setup_pi(&root).unwrap();
268        let ext = root.join(".pi").join("extensions").join("scc");
269        assert!(ext.join("index.ts").exists());
270        assert!(ext.join("update-check.ts").exists());
271        assert!(ext.join("package.json").exists());
272        let ts = std::fs::read_to_string(ext.join("index.ts")).unwrap();
273        assert!(ts.contains("@mariozechner/pi-coding-agent"), "pi scope retargeted");
274        assert!(!ts.contains("@oh-my-pi/"), "no fork scope remains");
275        assert!(ts.contains("maybeNotifyUpdate"), "notifier wired");
276        assert!(!ts.contains("trace:v1 id="), "authoring markers stripped");
277    }
278
279    use super::*;
280    use std::sync::{Mutex, MutexGuard};
281
282    static ENV_LOCK: Mutex<()> = Mutex::new(());
283
284    /// Holds the process-wide SCC_BIN lock for the lifetime of setup/merge
285    /// tests. `mcp_json_honors_scc_bin_at_setup` mutates the env; any parallel
286    /// test that reads SCC_BIN without this guard will flake (CI PR job:
287    /// `mcp_merge_preserves_existing_servers` saw `/opt/custom/scc`).
288    // trace:exempt reason=internal-helper
289    struct SccBinGuard {
290        _lock: MutexGuard<'static, ()>,
291        prev: Option<String>,
292    }
293
294    // trace:exempt reason=internal-helper
295    impl Drop for SccBinGuard {
296        // trace:exempt reason=internal-helper
297        fn drop(&mut self) {
298            match &self.prev {
299                Some(v) => std::env::set_var("SCC_BIN", v),
300                None => std::env::remove_var("SCC_BIN"),
301            }
302        }
303    }
304
305    // trace:exempt reason=internal-helper
306    fn lock_scc_bin(value: Option<&str>) -> SccBinGuard {
307        let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
308        let prev = std::env::var("SCC_BIN").ok();
309        match value {
310            Some(v) => std::env::set_var("SCC_BIN", v),
311            None => std::env::remove_var("SCC_BIN"),
312        }
313        SccBinGuard { _lock: lock, prev }
314    }
315
316    // trace:exempt reason=internal-helper
317    fn installed_extension(root: &Path) -> String {
318        std::fs::read_to_string(root.join(".omp/extensions/scc/index.ts")).unwrap()
319    }
320
321    #[test]
322    // trace:v1 id=test.scc-cli-plugin-omp.installs-extension-and-mcp work=WORK-SCC-001 verifies=REQ-SCC-API
323    fn installs_extension_and_mcp() {
324        let _env = lock_scc_bin(None);
325        let dir = tempfile::TempDir::new().unwrap();
326        let root = dir.path().join("repo");
327        std::fs::create_dir_all(&root).unwrap();
328        cmd_setup_omp(&root).unwrap();
329
330        // Extension files are present.
331        assert!(root.join(".omp/extensions/scc/index.ts").exists());
332        assert!(root.join(".omp/extensions/scc/update-check.ts").exists());
333        assert!(root.join(".omp/extensions/scc/package.json").exists());
334        let installed_ts = std::fs::read_to_string(root.join(".omp/extensions/scc/index.ts")).unwrap();
335        assert!(!installed_ts.contains("trace:v1 id="), "authoring markers stripped at install");
336        // The extension imports the resolvable package.
337        let ts = installed_extension(&root);
338        assert!(ts.contains("@oh-my-pi/pi-coding-agent"), "extension must import the canonical resolvable package");
339        assert!(ts.contains("before_agent_start"), "extension must wire before_agent_start");
340        assert!(ts.contains("session_start"), "extension must wire session_start");
341
342        // mcp.json has the scc server.
343        let mcp: serde_json::Value =
344            serde_json::from_str(&std::fs::read_to_string(root.join(".omp/mcp.json")).unwrap()).unwrap();
345        assert_eq!(mcp["mcpServers"]["scc"]["command"], "scc");
346        assert_eq!(mcp["mcpServers"]["scc"]["args"][0], "mcp");
347
348        // Neither file existed: canonical root AGENTS.md, no shadowing .omp/AGENTS.md.
349        assert!(root.join("AGENTS.md").exists(), "canonical instruction file is root AGENTS.md");
350        assert!(
351            !root.join(".omp/AGENTS.md").exists(),
352            "must not create shadowing .omp/AGENTS.md when root did not exist"
353        );
354        let agents = std::fs::read_to_string(root.join("AGENTS.md")).unwrap();
355        assert!(agents.contains("SCC (System Context Compiler)"), "AGENTS.md must carry SCC durable rules");
356        let skill = std::fs::read_to_string(root.join(".omp/skills/scc-system-context/SKILL.md")).unwrap();
357        assert!(skill.contains("`system_context`"), "skill must teach system_context");
358        assert!(skill.contains("`surface_map`"), "skill must teach surface_map");
359        assert!(skill.contains("`structural_source`"), "skill must teach structural_source");
360        assert!(!skill.contains("| `system_overview` |"), "skill table must not lead with the retired startup tool");
361    }
362
363    #[test]
364    // trace:v1 id=test.scc-cli-plugin-omp.mcp-merge-preserves-existing work=WORK-SCC-001 verifies=REQ-SCC-API
365    fn mcp_merge_preserves_existing_servers() {
366        let _env = lock_scc_bin(None);
367        let dir = tempfile::TempDir::new().unwrap();
368        let root = dir.path().join("repo");
369        std::fs::create_dir_all(&root).unwrap();
370        let omp = root.join(".omp");
371        std::fs::create_dir_all(&omp).unwrap();
372        std::fs::write(
373            omp.join("mcp.json"),
374            r#"{"mcpServers":{"existing":{"command":"foo","args":["bar"]}}}"#,
375        )
376        .unwrap();
377        merge_mcp_json(&omp).unwrap();
378        let mcp: serde_json::Value =
379            serde_json::from_str(&std::fs::read_to_string(omp.join("mcp.json")).unwrap()).unwrap();
380        assert_eq!(mcp["mcpServers"]["existing"]["command"], "foo", "existing server must be preserved");
381        assert_eq!(mcp["mcpServers"]["scc"]["command"], "scc", "scc server must be added");
382    }
383
384    #[test]
385    // trace:v1 id=test.scc-cli-plugin-omp.agents-idempotent-preserves-user work=WORK-SCC-001 verifies=REQ-SCC-API
386    fn agents_idempotent_preserves_user_content() {
387        let dir = tempfile::TempDir::new().unwrap();
388        let root = dir.path().join("repo");
389        std::fs::create_dir_all(&root).unwrap();
390        let omp = root.join(".omp");
391        std::fs::create_dir_all(&omp).unwrap();
392        std::fs::write(omp.join("AGENTS.md"), "USER CONTENT\n").unwrap();
393        write_agents_rules(&root).unwrap();
394        let first = std::fs::read_to_string(omp.join("AGENTS.md")).unwrap();
395        assert!(first.starts_with("USER CONTENT"), "user content must be preserved");
396        assert!(first.contains("SCC (System Context Compiler)"), "SCC rules must be appended");
397        // Idempotent: a second run does not duplicate the SCC section.
398        write_agents_rules(&root).unwrap();
399        let second = std::fs::read_to_string(omp.join("AGENTS.md")).unwrap();
400        assert_eq!(
401            first.matches("SCC (System Context Compiler)").count(),
402            second.matches("SCC (System Context Compiler)").count(),
403            "SCC rules must not duplicate on reinstall"
404        );
405    }
406
407    #[test]
408    // trace:v1 id=test.scc-cli-plugin-omp.agents-preserves-after-marker work=WORK-SCC-001 verifies=REQ-SCC-API,REQ-implement-p0-omp-integration-correctness-and-writable-benchmark-scient
409    fn agents_rewrite_preserves_text_after_closing_marker() {
410        let dir = tempfile::TempDir::new().unwrap();
411        let root = dir.path().join("repo");
412        std::fs::create_dir_all(root.join(".omp")).unwrap();
413        std::fs::write(
414            root.join(".omp/AGENTS.md"),
415            "BEFORE\n<!-- SCC-OMP-SECTION -->\nold managed\n<!-- /SCC-OMP-SECTION -->\nKEEP AFTER\n",
416        )
417        .unwrap();
418        write_agents_rules(&root).unwrap();
419        let text = std::fs::read_to_string(root.join(".omp/AGENTS.md")).unwrap();
420        assert!(text.contains("BEFORE"), "{text}");
421        assert!(text.contains("KEEP AFTER"), "text after the closing marker must survive: {text}");
422        assert!(!text.contains("old managed"), "{text}");
423    }
424
425    #[test]
426    // trace:v1 id=test.scc-cli-plugin-omp.agents-patches-root-not-shadow work=WORK-SCC-001 verifies=REQ-SCC-API,REQ-implement-p0-omp-integration-correctness-and-writable-benchmark-scient
427    fn agents_patches_existing_root_and_does_not_create_shadow() {
428        let dir = tempfile::TempDir::new().unwrap();
429        let root = dir.path().join("repo");
430        std::fs::create_dir_all(&root).unwrap();
431        std::fs::write(root.join("AGENTS.md"), "# project rules\nkeep me\n").unwrap();
432        let path = write_agents_rules(&root).unwrap();
433        assert_eq!(path, root.join("AGENTS.md"));
434        assert!(!root.join(".omp/AGENTS.md").exists(), "must not create shadowing .omp/AGENTS.md");
435        let text = std::fs::read_to_string(root.join("AGENTS.md")).unwrap();
436        assert!(text.contains("keep me"), "{text}");
437        assert!(text.contains("SCC (System Context Compiler)"), "{text}");
438    }
439
440    #[test]
441    // trace:v1 id=test.scc-cli-plugin-omp.omp-codex-sections-coexist work=WORK-SCC-001 verifies=REQ-SCC-API
442    fn omp_and_codex_sections_coexist_in_one_file() {
443        // The OMP and Codex installers share instruction files; each owns
444        // a DISTINCT marker pair so neither installer deletes the other's
445        // section (last-writer-wins data loss). Order-independent.
446        let dir = tempfile::TempDir::new().unwrap();
447        let root = dir.path().join("repo");
448        std::fs::create_dir_all(&root).unwrap();
449        std::fs::write(
450            root.join("AGENTS.md"),
451            "# user\n<!-- SCC-SECTION -->\ncodex capsule\n<!-- /SCC-SECTION -->\n",
452        )
453        .unwrap();
454        write_agents_rules(&root).unwrap();
455        let text = std::fs::read_to_string(root.join("AGENTS.md")).unwrap();
456        assert!(text.contains("codex capsule"), "codex section must survive OMP setup: {text}");
457        assert!(text.contains("SCC-OMP-SECTION"), "OMP section must be installed: {text}");
458        // Reinstall: both sections still present exactly once.
459        write_agents_rules(&root).unwrap();
460        let again = std::fs::read_to_string(root.join("AGENTS.md")).unwrap();
461        assert_eq!(again.matches("SCC-OMP-SECTION").count(), 2, "one OMP section: {again}");
462        assert!(again.contains("codex capsule"), "codex section survives reinstall: {again}");
463    }
464
465    #[test]
466    // trace:v1 id=test.scc-cli-plugin-omp.extension-index-uses-paths work=WORK-SCC-001 verifies=REQ-SCC-API,REQ-implement-p0-omp-integration-correctness-and-writable-benchmark-scient
467    fn generated_extension_invokes_index_paths_and_does_not_treat_failure_as_success() {
468        let _env = lock_scc_bin(None);
469        let dir = tempfile::TempDir::new().unwrap();
470        let root = dir.path().join("repo");
471        std::fs::create_dir_all(&root).unwrap();
472        cmd_setup_omp(&root).unwrap();
473        let ts = installed_extension(&root);
474        assert!(
475            ts.contains("\"--paths\"") || ts.contains("[\"index\", \"--paths\""),
476            "generated extension must invoke `scc index --paths` (plural): {ts}"
477        );
478        assert!(
479            !ts.contains("\"--path\""),
480            "must not pass the rejected singular --path flag: {ts}"
481        );
482        assert!(
483            ts.contains("index --paths failed") || ts.contains("did not succeed"),
484            "failed index must be reported, not treated as success: {ts}"
485        );
486        assert!(
487            ts.contains("session.compacting") && ts.contains("session_before_compact"),
488            "compaction must save on session_before_compact and inject on session.compacting: {ts}"
489        );
490        assert!(
491            ts.contains("checkpoint") && ts.contains("load") && ts.contains("--inject"),
492            "compacting must load the checkpoint with --inject: {ts}"
493        );
494        assert!(
495            ts.contains("process.env.SCC_BIN") || ts.contains("SCC_BIN"),
496            "extension must honor SCC_BIN: {ts}"
497        );
498        assert!(
499            ts.contains("session_switch") && ts.contains("session_branch") && ts.contains("session_tree"),
500            "injection marker must reset on switch/branch/tree: {ts}"
501        );
502        assert!(
503            ts.contains("hash-object") || ts.contains("porcelain"),
504            "opaque mutations must snapshot dirty files: {ts}"
505        );
506        assert!(
507            ts.contains("--porcelain=v1") && ts.contains("-z"),
508            "porcelain must be NUL-delimited (-z): {ts}"
509        );
510        assert!(
511            ts.contains("\"--name-only\"") || ts.contains("diff\", \"--name-only\""),
512            "HEAD revision diffs must use --name-only: {ts}"
513        );
514        assert!(
515            ts.contains("split(\"\\0\")") || ts.contains("split('\\0')") || ts.contains(".split(\"\\0\")"),
516            "revision diffs must split on NUL, not newlines: {ts}"
517        );
518        assert!(
519            ts.contains("rev-parse") && ts.contains("HEAD"),
520            "snapshots must record HEAD so clean-to-clean mutations refresh: {ts}"
521        );
522        assert!(
523            !ts.contains("toolName}:${Date.now()") && !ts.contains("toolName}:${Date.now()}"),
524            "dirtySnapshots must not use a Date.now() fallback id: {ts}"
525        );
526        assert!(
527            ts.contains("if (!id) return"),
528            "snapshots without toolCallId must be skipped: {ts}"
529        );
530        assert!(
531            ts.contains("startupOk"),
532            "compaction must track startup injection separately from checkpoint: {ts}"
533        );
534    }
535
536    #[test]
537    // trace:v1 id=test.scc-cli-plugin-omp.scc-bin-honored-in-mcp work=WORK-SCC-001 verifies=REQ-SCC-API,REQ-implement-p0-omp-integration-correctness-and-writable-benchmark-scient
538    fn mcp_json_honors_scc_bin_at_setup() {
539        let _env = lock_scc_bin(Some("/opt/custom/scc"));
540        let dir = tempfile::TempDir::new().unwrap();
541        let root = dir.path().join("repo");
542        std::fs::create_dir_all(&root).unwrap();
543        cmd_setup_omp(&root).unwrap();
544        let mcp: serde_json::Value =
545            serde_json::from_str(&std::fs::read_to_string(root.join(".omp/mcp.json")).unwrap()).unwrap();
546        assert_eq!(mcp["mcpServers"]["scc"]["command"], "/opt/custom/scc");
547        let ts = installed_extension(&root);
548        assert!(ts.contains("process.env.SCC_BIN"), "{ts}");
549    }
550
551    #[test]
552    // trace:v1 id=test.scc-cli-plugin-omp.setup-says-restart-then-verify work=WORK-SCC-001 verifies=REQ-SCC-API
553    fn setup_instructions_do_not_claim_extensions_reloads() {
554        // The installer prints to stdout; capture by checking the source
555        // contract the user sees after setup (the function's println!s).
556        let src = include_str!("plugin_omp.rs");
557        assert!(
558            src.contains("Restart OMP, then run `/extensions` to verify"),
559            "instructions must say restart, then /extensions to verify"
560        );
561        assert!(
562            src.contains("`/extensions` is an inspector, not a reload"),
563            "must say /extensions is an inspector"
564        );
565        // The user-facing installer must not tell operators that /extensions reloads.
566        let installer = src.split("#[cfg(test)]").next().unwrap();
567        assert!(
568            !installer.contains("or run `/extensions` to reload"),
569            "/extensions is an inspector, not a reload"
570        );
571    }
572}