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 in the file's own comment style; all other lines
110// pass through byte-identical.
111// trace:exempt reason=internal-helper
112pub(crate) fn strip_authoring_markers(src: &str) -> String {
113    let mut out = String::with_capacity(src.len());
114    for line in src.split('\n') {
115        let t = line.trim_start();
116        let style = if t.starts_with("// trace:v1 ") {
117            Some("//")
118        } else if t.starts_with("# trace:v1 ") {
119            Some("#")
120        } else if t.starts_with("-- trace:v1 ") {
121            Some("--")
122        } else if t.starts_with("<!-- trace:v1 ") {
123            Some("<!--")
124        } else {
125            None
126        };
127        if let Some(c) = style {
128            let indent = &line[..line.len() - t.len()];
129            let close = if c == "<!--" { " -->" } else { "" };
130            out.push_str(indent);
131            out.push_str(c);
132            out.push_str(" trace:exempt reason=scc-installed-tooling (authoring marker from the SCC source repo removed at install)");
133            out.push_str(close);
134        } else {
135            out.push_str(line);
136        }
137        out.push('\n');
138    }
139    if src.ends_with('\n') {
140        out.pop();
141    }
142    out
143}
144
145// trace:exempt reason=internal-helper
146fn installable_aux_ts(src: &str) -> String {
147    strip_authoring_markers(src)
148}
149
150// Upstream Pi resolves the same extension API under its own scope; the
151// extension body only uses pi.on/pi.exec/ctx.ui/ctx.cwd, which exist on
152// both sides, so a scope rewrite is the entire port.
153// trace:v1 id=impl.crates-scc-cli-src-plugin-omp.cmd-setup-pi work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
154pub fn cmd_setup_pi(root: &Path) -> crate::Result<()> {
155    let ext_dir = root.join(".pi").join("extensions").join("scc");
156    std::fs::create_dir_all(&ext_dir)?;
157    std::fs::write(ext_dir.join("index.ts"), installable_pi_ts(EXTENSION_TS))?;
158    std::fs::write(ext_dir.join("update-check.ts"), installable_aux_ts(UPDATE_CHECK_TS))?;
159    std::fs::write(ext_dir.join("package.json"), extension_package())?;
160    println!("wrote {}", ext_dir.join("index.ts").display());
161    println!();
162    println!("Pi integration installed (project-local .pi/extensions/scc).");
163    println!("Restart Pi; the extension hot-reloads with /reload.");
164    println!("The `scc` binary must be on PATH, or set SCC_BIN.");
165    Ok(())
166}
167
168// trace:exempt reason=internal-helper
169fn installable_pi_ts(src: &str) -> String {
170    strip_authoring_markers(src).replace(
171        "@oh-my-pi/pi-coding-agent",
172        "@mariozechner/pi-coding-agent",
173    )
174}
175
176// trace:exempt reason=internal-helper
177fn extension_package() -> &'static str {
178    EXTENSION_PACKAGE
179}
180
181// trace:exempt reason=internal-helper
182fn skill_md() -> &'static str {
183    SKILL_MD
184}
185
186// trace:exempt reason=internal-helper
187fn scc_bin() -> String {
188    if let Ok(v) = std::env::var("SCC_BIN") {
189        let v = v.trim().to_string();
190        if !v.is_empty() {
191            return v;
192        }
193    }
194    // Absolute path only when the current exe IS the scc binary (exact
195    // file-stem match — a test-harness or build-tool exe path must never
196    // leak into generated configs).
197    if let Ok(exe) = std::env::current_exe() {
198        if exe.file_stem().map(|n| n == "scc").unwrap_or(false) {
199            return exe.to_string_lossy().into_owned();
200        }
201    }
202    "scc".to_string()
203}
204
205/// Merge the SCC MCP server into `.omp/mcp.json`, preserving any existing
206/// servers. The format is the standard MCP config: `{ "mcpServers": {
207/// "<name>": { "command": ..., "args": [...] } } }`. Idempotent: if the
208/// `scc` server is already present, it is left unchanged unless SCC_BIN
209/// is set (then the command is updated to the known static path).
210// trace:v1 id=impl.crates-scc-cli-src-plugin-omp.merge-mcp-json work=WORK-SCC-001 satisfies=REQ-SCC-API
211fn merge_mcp_json(omp_dir: &Path) -> crate::Result<()> {
212    let path = omp_dir.join("mcp.json");
213    let mut v: serde_json::Value = if path.exists() {
214        let text = std::fs::read_to_string(&path)?;
215        serde_json::from_str(&text).unwrap_or(serde_json::json!({}))
216    } else {
217        serde_json::json!({})
218    };
219    let servers = v
220        .get_mut("mcpServers")
221        .and_then(|m| m.as_object_mut())
222        .cloned()
223        .unwrap_or_default();
224    let mut servers = servers;
225    let cmd = scc_bin();
226    match servers.get_mut("scc") {
227        Some(existing) => {
228            if std::env::var("SCC_BIN").map(|s| !s.trim().is_empty()).unwrap_or(false) {
229                existing["command"] = serde_json::Value::String(cmd);
230            }
231        }
232        None => {
233            servers.insert(
234                "scc".to_string(),
235                serde_json::json!({"command": cmd, "args": ["mcp"]}),
236            );
237        }
238    }
239    v["mcpServers"] = serde_json::Value::Object(servers);
240    std::fs::write(&path, serde_json::to_string_pretty(&v)?)?;
241    Ok(())
242}
243
244/// Write the durable SCC rules into the OMP-winning AGENTS.md, preserving
245/// user content on BOTH sides of the managed section (idempotent).
246// trace:v1 id=impl.crates-scc-cli-src-plugin-omp.write-agents-rules work=WORK-SCC-001 satisfies=REQ-SCC-API
247fn write_agents_rules(root: &Path) -> crate::Result<std::path::PathBuf> {
248    let resolution = resolve_omp_agents_path(root);
249    if resolution.both_exist {
250        eprintln!(
251            "warning: both .omp/AGENTS.md and AGENTS.md exist; OMP prefers .omp/AGENTS.md \
252             (higher-priority same-scope context) and will shadow the root file. \
253             Patching .omp/AGENTS.md. Remove one of the files if that precedence is unintended."
254        );
255    }
256    let path = match resolution.target {
257        AgentsTarget::Omp => root.join(".omp").join("AGENTS.md"),
258        AgentsTarget::Root => root.join("AGENTS.md"),
259    };
260    if let Some(parent) = path.parent() {
261        std::fs::create_dir_all(parent)?;
262    }
263    let existing = std::fs::read_to_string(&path).unwrap_or_default();
264    let out = replace_scc_section_in(
265        &existing,
266        AGENTS_RULES,
267        SCC_OMP_SECTION_OPEN,
268        SCC_OMP_SECTION_CLOSE,
269    );
270    std::fs::write(&path, out)?;
271    Ok(path)
272}
273
274#[cfg(test)]
275mod tests {
276    #[test]
277    // trace:exempt reason=unit-test
278    fn pi_setup_installs_retargeted_extension() {
279        let dir = tempfile::TempDir::new().unwrap();
280        let root = dir.path().join("repo");
281        std::fs::create_dir_all(&root).unwrap();
282        cmd_setup_pi(&root).unwrap();
283        let ext = root.join(".pi").join("extensions").join("scc");
284        assert!(ext.join("index.ts").exists());
285        assert!(ext.join("update-check.ts").exists());
286        assert!(ext.join("package.json").exists());
287        let ts = std::fs::read_to_string(ext.join("index.ts")).unwrap();
288        assert!(ts.contains("@mariozechner/pi-coding-agent"), "pi scope retargeted");
289        assert!(!ts.contains("@oh-my-pi/"), "no fork scope remains");
290        assert!(ts.contains("maybeNotifyUpdate"), "notifier wired");
291        assert!(!ts.contains("trace:v1 id="), "authoring markers stripped");
292    }
293
294    use super::*;
295    use std::sync::{Mutex, MutexGuard};
296
297    static ENV_LOCK: Mutex<()> = Mutex::new(());
298
299    /// Holds the process-wide SCC_BIN lock for the lifetime of setup/merge
300    /// tests. `mcp_json_honors_scc_bin_at_setup` mutates the env; any parallel
301    /// test that reads SCC_BIN without this guard will flake (CI PR job:
302    /// `mcp_merge_preserves_existing_servers` saw `/opt/custom/scc`).
303    // trace:exempt reason=internal-helper
304    struct SccBinGuard {
305        _lock: MutexGuard<'static, ()>,
306        prev: Option<String>,
307    }
308
309    // trace:exempt reason=internal-helper
310    impl Drop for SccBinGuard {
311        // trace:exempt reason=internal-helper
312        fn drop(&mut self) {
313            match &self.prev {
314                Some(v) => std::env::set_var("SCC_BIN", v),
315                None => std::env::remove_var("SCC_BIN"),
316            }
317        }
318    }
319
320    // trace:exempt reason=internal-helper
321    fn lock_scc_bin(value: Option<&str>) -> SccBinGuard {
322        let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
323        let prev = std::env::var("SCC_BIN").ok();
324        match value {
325            Some(v) => std::env::set_var("SCC_BIN", v),
326            None => std::env::remove_var("SCC_BIN"),
327        }
328        SccBinGuard { _lock: lock, prev }
329    }
330
331    // trace:exempt reason=internal-helper
332    fn installed_extension(root: &Path) -> String {
333        std::fs::read_to_string(root.join(".omp/extensions/scc/index.ts")).unwrap()
334    }
335
336    #[test]
337    // trace:v1 id=test.scc-cli-plugin-omp.installs-extension-and-mcp work=WORK-SCC-001 verifies=REQ-SCC-API
338    fn installs_extension_and_mcp() {
339        let _env = lock_scc_bin(None);
340        let dir = tempfile::TempDir::new().unwrap();
341        let root = dir.path().join("repo");
342        std::fs::create_dir_all(&root).unwrap();
343        cmd_setup_omp(&root).unwrap();
344
345        // Extension files are present.
346        assert!(root.join(".omp/extensions/scc/index.ts").exists());
347        assert!(root.join(".omp/extensions/scc/update-check.ts").exists());
348        assert!(root.join(".omp/extensions/scc/package.json").exists());
349        let installed_ts = std::fs::read_to_string(root.join(".omp/extensions/scc/index.ts")).unwrap();
350        assert!(!installed_ts.contains("trace:v1 id="), "authoring markers stripped at install");
351        // The extension imports the resolvable package.
352        let ts = installed_extension(&root);
353        assert!(ts.contains("@oh-my-pi/pi-coding-agent"), "extension must import the canonical resolvable package");
354        assert!(ts.contains("before_agent_start"), "extension must wire before_agent_start");
355        assert!(ts.contains("session_start"), "extension must wire session_start");
356
357        // mcp.json has the scc server.
358        let mcp: serde_json::Value =
359            serde_json::from_str(&std::fs::read_to_string(root.join(".omp/mcp.json")).unwrap()).unwrap();
360        assert_eq!(mcp["mcpServers"]["scc"]["command"], "scc");
361        assert_eq!(mcp["mcpServers"]["scc"]["args"][0], "mcp");
362
363        // Neither file existed: canonical root AGENTS.md, no shadowing .omp/AGENTS.md.
364        assert!(root.join("AGENTS.md").exists(), "canonical instruction file is root AGENTS.md");
365        assert!(
366            !root.join(".omp/AGENTS.md").exists(),
367            "must not create shadowing .omp/AGENTS.md when root did not exist"
368        );
369        let agents = std::fs::read_to_string(root.join("AGENTS.md")).unwrap();
370        assert!(agents.contains("SCC (System Context Compiler)"), "AGENTS.md must carry SCC durable rules");
371        let skill = std::fs::read_to_string(root.join(".omp/skills/scc-system-context/SKILL.md")).unwrap();
372        assert!(skill.contains("`system_context`"), "skill must teach system_context");
373        assert!(skill.contains("`surface_map`"), "skill must teach surface_map");
374        assert!(skill.contains("`structural_source`"), "skill must teach structural_source");
375        assert!(!skill.contains("| `system_overview` |"), "skill table must not lead with the retired startup tool");
376    }
377
378    #[test]
379    // trace:v1 id=test.scc-cli-plugin-omp.mcp-merge-preserves-existing work=WORK-SCC-001 verifies=REQ-SCC-API
380    fn mcp_merge_preserves_existing_servers() {
381        let _env = lock_scc_bin(None);
382        let dir = tempfile::TempDir::new().unwrap();
383        let root = dir.path().join("repo");
384        std::fs::create_dir_all(&root).unwrap();
385        let omp = root.join(".omp");
386        std::fs::create_dir_all(&omp).unwrap();
387        std::fs::write(
388            omp.join("mcp.json"),
389            r#"{"mcpServers":{"existing":{"command":"foo","args":["bar"]}}}"#,
390        )
391        .unwrap();
392        merge_mcp_json(&omp).unwrap();
393        let mcp: serde_json::Value =
394            serde_json::from_str(&std::fs::read_to_string(omp.join("mcp.json")).unwrap()).unwrap();
395        assert_eq!(mcp["mcpServers"]["existing"]["command"], "foo", "existing server must be preserved");
396        assert_eq!(mcp["mcpServers"]["scc"]["command"], "scc", "scc server must be added");
397    }
398
399    #[test]
400    // trace:v1 id=test.scc-cli-plugin-omp.agents-idempotent-preserves-user work=WORK-SCC-001 verifies=REQ-SCC-API
401    fn agents_idempotent_preserves_user_content() {
402        let dir = tempfile::TempDir::new().unwrap();
403        let root = dir.path().join("repo");
404        std::fs::create_dir_all(&root).unwrap();
405        let omp = root.join(".omp");
406        std::fs::create_dir_all(&omp).unwrap();
407        std::fs::write(omp.join("AGENTS.md"), "USER CONTENT\n").unwrap();
408        write_agents_rules(&root).unwrap();
409        let first = std::fs::read_to_string(omp.join("AGENTS.md")).unwrap();
410        assert!(first.starts_with("USER CONTENT"), "user content must be preserved");
411        assert!(first.contains("SCC (System Context Compiler)"), "SCC rules must be appended");
412        // Idempotent: a second run does not duplicate the SCC section.
413        write_agents_rules(&root).unwrap();
414        let second = std::fs::read_to_string(omp.join("AGENTS.md")).unwrap();
415        assert_eq!(
416            first.matches("SCC (System Context Compiler)").count(),
417            second.matches("SCC (System Context Compiler)").count(),
418            "SCC rules must not duplicate on reinstall"
419        );
420    }
421
422    #[test]
423    // 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
424    fn agents_rewrite_preserves_text_after_closing_marker() {
425        let dir = tempfile::TempDir::new().unwrap();
426        let root = dir.path().join("repo");
427        std::fs::create_dir_all(root.join(".omp")).unwrap();
428        std::fs::write(
429            root.join(".omp/AGENTS.md"),
430            "BEFORE\n<!-- SCC-OMP-SECTION -->\nold managed\n<!-- /SCC-OMP-SECTION -->\nKEEP AFTER\n",
431        )
432        .unwrap();
433        write_agents_rules(&root).unwrap();
434        let text = std::fs::read_to_string(root.join(".omp/AGENTS.md")).unwrap();
435        assert!(text.contains("BEFORE"), "{text}");
436        assert!(text.contains("KEEP AFTER"), "text after the closing marker must survive: {text}");
437        assert!(!text.contains("old managed"), "{text}");
438    }
439
440    #[test]
441    // 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
442    fn agents_patches_existing_root_and_does_not_create_shadow() {
443        let dir = tempfile::TempDir::new().unwrap();
444        let root = dir.path().join("repo");
445        std::fs::create_dir_all(&root).unwrap();
446        std::fs::write(root.join("AGENTS.md"), "# project rules\nkeep me\n").unwrap();
447        let path = write_agents_rules(&root).unwrap();
448        assert_eq!(path, root.join("AGENTS.md"));
449        assert!(!root.join(".omp/AGENTS.md").exists(), "must not create shadowing .omp/AGENTS.md");
450        let text = std::fs::read_to_string(root.join("AGENTS.md")).unwrap();
451        assert!(text.contains("keep me"), "{text}");
452        assert!(text.contains("SCC (System Context Compiler)"), "{text}");
453    }
454
455    #[test]
456    // trace:v1 id=test.scc-cli-plugin-omp.omp-codex-sections-coexist work=WORK-SCC-001 verifies=REQ-SCC-API
457    fn omp_and_codex_sections_coexist_in_one_file() {
458        // The OMP and Codex installers share instruction files; each owns
459        // a DISTINCT marker pair so neither installer deletes the other's
460        // section (last-writer-wins data loss). Order-independent.
461        let dir = tempfile::TempDir::new().unwrap();
462        let root = dir.path().join("repo");
463        std::fs::create_dir_all(&root).unwrap();
464        std::fs::write(
465            root.join("AGENTS.md"),
466            "# user\n<!-- SCC-SECTION -->\ncodex capsule\n<!-- /SCC-SECTION -->\n",
467        )
468        .unwrap();
469        write_agents_rules(&root).unwrap();
470        let text = std::fs::read_to_string(root.join("AGENTS.md")).unwrap();
471        assert!(text.contains("codex capsule"), "codex section must survive OMP setup: {text}");
472        assert!(text.contains("SCC-OMP-SECTION"), "OMP section must be installed: {text}");
473        // Reinstall: both sections still present exactly once.
474        write_agents_rules(&root).unwrap();
475        let again = std::fs::read_to_string(root.join("AGENTS.md")).unwrap();
476        assert_eq!(again.matches("SCC-OMP-SECTION").count(), 2, "one OMP section: {again}");
477        assert!(again.contains("codex capsule"), "codex section survives reinstall: {again}");
478    }
479
480    #[test]
481    // 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
482    fn generated_extension_invokes_index_paths_and_does_not_treat_failure_as_success() {
483        let _env = lock_scc_bin(None);
484        let dir = tempfile::TempDir::new().unwrap();
485        let root = dir.path().join("repo");
486        std::fs::create_dir_all(&root).unwrap();
487        cmd_setup_omp(&root).unwrap();
488        let ts = installed_extension(&root);
489        assert!(
490            ts.contains("\"--paths\"") || ts.contains("[\"index\", \"--paths\""),
491            "generated extension must invoke `scc index --paths` (plural): {ts}"
492        );
493        assert!(
494            !ts.contains("\"--path\""),
495            "must not pass the rejected singular --path flag: {ts}"
496        );
497        assert!(
498            ts.contains("index --paths failed") || ts.contains("did not succeed"),
499            "failed index must be reported, not treated as success: {ts}"
500        );
501        assert!(
502            ts.contains("session.compacting") && ts.contains("session_before_compact"),
503            "compaction must save on session_before_compact and inject on session.compacting: {ts}"
504        );
505        assert!(
506            ts.contains("checkpoint") && ts.contains("load") && ts.contains("--inject"),
507            "compacting must load the checkpoint with --inject: {ts}"
508        );
509        assert!(
510            ts.contains("process.env.SCC_BIN") || ts.contains("SCC_BIN"),
511            "extension must honor SCC_BIN: {ts}"
512        );
513        assert!(
514            ts.contains("session_switch") && ts.contains("session_branch") && ts.contains("session_tree"),
515            "injection marker must reset on switch/branch/tree: {ts}"
516        );
517        assert!(
518            ts.contains("hash-object") || ts.contains("porcelain"),
519            "opaque mutations must snapshot dirty files: {ts}"
520        );
521        assert!(
522            ts.contains("--porcelain=v1") && ts.contains("-z"),
523            "porcelain must be NUL-delimited (-z): {ts}"
524        );
525        assert!(
526            ts.contains("\"--name-only\"") || ts.contains("diff\", \"--name-only\""),
527            "HEAD revision diffs must use --name-only: {ts}"
528        );
529        assert!(
530            ts.contains("split(\"\\0\")") || ts.contains("split('\\0')") || ts.contains(".split(\"\\0\")"),
531            "revision diffs must split on NUL, not newlines: {ts}"
532        );
533        assert!(
534            ts.contains("rev-parse") && ts.contains("HEAD"),
535            "snapshots must record HEAD so clean-to-clean mutations refresh: {ts}"
536        );
537        assert!(
538            !ts.contains("toolName}:${Date.now()") && !ts.contains("toolName}:${Date.now()}"),
539            "dirtySnapshots must not use a Date.now() fallback id: {ts}"
540        );
541        assert!(
542            ts.contains("if (!id) return"),
543            "snapshots without toolCallId must be skipped: {ts}"
544        );
545        assert!(
546            ts.contains("startupOk"),
547            "compaction must track startup injection separately from checkpoint: {ts}"
548        );
549    }
550
551    #[test]
552    // 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
553    fn mcp_json_honors_scc_bin_at_setup() {
554        let _env = lock_scc_bin(Some("/opt/custom/scc"));
555        let dir = tempfile::TempDir::new().unwrap();
556        let root = dir.path().join("repo");
557        std::fs::create_dir_all(&root).unwrap();
558        cmd_setup_omp(&root).unwrap();
559        let mcp: serde_json::Value =
560            serde_json::from_str(&std::fs::read_to_string(root.join(".omp/mcp.json")).unwrap()).unwrap();
561        assert_eq!(mcp["mcpServers"]["scc"]["command"], "/opt/custom/scc");
562        let ts = installed_extension(&root);
563        assert!(ts.contains("process.env.SCC_BIN"), "{ts}");
564    }
565
566    #[test]
567    // trace:v1 id=test.scc-cli-plugin-omp.setup-says-restart-then-verify work=WORK-SCC-001 verifies=REQ-SCC-API
568    fn setup_instructions_do_not_claim_extensions_reloads() {
569        // The installer prints to stdout; capture by checking the source
570        // contract the user sees after setup (the function's println!s).
571        let src = include_str!("plugin_omp.rs");
572        assert!(
573            src.contains("Restart OMP, then run `/extensions` to verify"),
574            "instructions must say restart, then /extensions to verify"
575        );
576        assert!(
577            src.contains("`/extensions` is an inspector, not a reload"),
578            "must say /extensions is an inspector"
579        );
580        // The user-facing installer must not tell operators that /extensions reloads.
581        let installer = src.split("#[cfg(test)]").next().unwrap();
582        assert!(
583            !installer.contains("or run `/extensions` to reload"),
584            "/extensions is an inspector, not a reload"
585        );
586    }
587}