Skip to main content

scc_cli/
commands.rs

1//! Command implementations for the `scc` CLI (docs/API_AND_INTEGRATIONS.md §4).
2
3use crate::{config_path, load_config, open_store, scc_dir};
4
5// trace:exempt reason=internal-detail
6fn engine_err(e: scc_engine::EngineError) -> crate::CliError {
7    crate::CliError::Other(e.to_string())
8}
9use std::io::Write;
10use std::path::{Path, PathBuf};
11
12// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-init work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
13pub fn cmd_init(root: &Path) -> crate::Result<()> {
14    let dir = scc_dir(root);
15    std::fs::create_dir_all(&dir)?;
16    let cfg_path = config_path(root);
17    if !cfg_path.exists() {
18        std::fs::write(&cfg_path, scc_indexer::Config::default_yaml())?;
19        println!("created {}", cfg_path.display());
20    } else {
21        println!("config exists: {}", cfg_path.display());
22    }
23    // create the DB so the workspace is ready
24    let store = open_store(root)?;
25    crate::ensure_scc_ignored(root);
26    println!(
27        "initialized SCC workspace for repository '{}' at {}",
28        store.repo_name,
29        dir.display()
30    );
31    println!("next: scc index");
32    Ok(())
33}
34
35// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-index work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
36pub fn cmd_index(root: &Path, quiet: bool) -> crate::Result<()> {
37    let config = load_config(root)?;
38    let report = scc_engine::index::full(root, &config).map_err(engine_err)?;
39    if !quiet {
40        println!(
41            "indexed {} file(s) ({} changed, {} added, {} removed, {} failed) in {:.2}s",
42            report.indexed,
43            report.changed,
44            report.added,
45            report.removed,
46            report.failed,
47            report.duration_ms as f64 / 1000.0
48        );
49        println!("analysis_quality: {}", report.analysis_quality.compact_line());
50        let s = &report.scan_stats;
51        println!(
52            "files: discovered={} indexed={} ignored={} unsupported={} oversized={} unreadable={}",
53            s.discovered, s.indexed, s.ignored, s.unsupported, s.oversized, s.unreadable
54        );
55    }
56    Ok(())
57}
58
59// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-index-paths work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
60pub fn cmd_index_paths(root: &Path, paths: &[String], quiet: bool) -> crate::Result<()> {
61    let config = load_config(root)?;
62    let report = scc_engine::index::refresh_paths(root, &config, paths).map_err(engine_err)?;
63    if !quiet && report.indexed > 0 {
64        println!("refreshed {} file(s)", report.indexed);
65    }
66    Ok(())
67}
68
69// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-status work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
70pub fn cmd_status(root: &Path) -> crate::Result<()> {
71    let store = open_store(root)?;
72    let s = scc_engine::status::status(&store).map_err(engine_err)?;
73    println!("Repository: {} ({})", s.repository, s.repository_id);
74    if let Some(url) = &s.remote {
75        println!("Remote: {url}");
76    }
77    if s.indexed {
78        println!("Revision: {}", s.revision);
79        if let Some(b) = &s.branch {
80            println!("Branch: {b}");
81        }
82        println!("Indexed at: {}", s.indexed_at.as_deref().unwrap_or(""));
83        let mut keys: Vec<&String> = s.stats.keys().collect();
84        keys.sort();
85        for k in keys {
86            println!("{k}: {}", s.stats[k]);
87        }
88        if s.freshness == "CURRENT" {
89            println!("freshness: CURRENT — model matches working tree");
90        } else {
91            println!(
92                "freshness: STALE — {} file(s) changed since index (run `scc index`)",
93                s.stale_count
94            );
95            for f in &s.stale_files {
96                println!("  {f}");
97            }
98        }
99        if let Some(raw) = &s.analysis_quality {
100            match serde_json::from_str::<scc_core::AnalysisQuality>(raw) {
101                Ok(q) => println!("analysis_quality: {}", q.compact_line()),
102                Err(_) => println!("analysis_quality: {raw}"),
103            }
104        }
105        if let Some(v) = &s.scan_stats {
106            // Counts only, recorded at index time; live staleness is
107            // reported above from the working-tree scan.
108            let n = |k: &str| v.get(k).and_then(|x| x.as_u64()).unwrap_or(0);
109            println!(
110                "files: discovered={} indexed={} ignored={} unsupported={} oversized={} unreadable={}",
111                n("discovered"),
112                n("indexed"),
113                n("ignored"),
114                n("unsupported"),
115                n("oversized"),
116                n("unreadable")
117            );
118        }
119    } else {
120        println!("not indexed yet — run `scc index`");
121    }
122    Ok(())
123}
124
125/// `scc languages` — generated support matrix. Never hand-maintain a
126/// second list in CLI copy.
127// trace:v1 id=impl.scc.cli.languages work=WORK-ripwire-lessons-phase1 satisfies=REQ-language-support-matrix
128pub fn cmd_languages() -> crate::Result<()> {
129    print!("{}", scc_core::support_matrix_markdown());
130    Ok(())
131}
132
133// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-overview work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
134pub fn cmd_overview(root: &Path, json: bool) -> crate::Result<()> {
135    let store = open_store(root)?;
136    let config = load_config(root)?;
137    let stale = crate::stale_paths(&store)?;
138    let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
139    let pack = engine.context().overview().map_err(engine_err)?;
140    if json {
141        println!("{}", serde_json::to_string_pretty(&pack)?);
142    } else {
143        print!("{}", pack.content);
144    }
145    Ok(())
146}
147// trace:v1 id=impl.scc.cli work=WORK-SCC-001 satisfies=REQ-SCC-API
148
149/// `scc atlas [--budget N] [--json]` — the full System Atlas.
150// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-atlas work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
151pub fn cmd_atlas(
152    root: &Path,
153    budget: Option<usize>,
154    json: bool,
155    full: bool,
156    unbounded: bool,
157) -> crate::Result<()> {
158    let store = open_store(root)?;
159    let config = load_config(root)?;
160    let stale = crate::stale_paths(&store)?;
161    let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
162    let pack = engine.context().atlas(budget, full, unbounded).map_err(engine_err)?;
163    if json {
164        println!("{}", serde_json::to_string(&pack)?);
165    } else {
166        print!("{}", pack.content);
167    }
168    Ok(())
169}
170
171/// `scc context startup [--budget N]` — the Wave 14 startup artifact:
172/// the Atlas + Surface fusion, deterministic per epoch (prompt-cache
173/// stable). Records what the session just showed in the context ledger so
174/// task deltas suppress already-visible APIs.
175// trace:exempt reason=internal-detail
176// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-context-startup work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
177pub fn cmd_context_startup(root: &Path, budget_tokens: Option<usize>) -> crate::Result<()> {
178    let store = open_store(root)?;
179    let config = load_config(root)?;
180    let stale = crate::stale_paths(&store)?;
181    let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
182    let (_startup, text) = engine.context().startup(&scc_api::StartupRequest { budget: budget_tokens }).map_err(engine_err)?;
183    print!("{text}");
184    Ok(())
185}
186
187/// `scc surface [--task "<goal>"] [--budget N] [--explain]` — the System
188/// Surface Map: the actual callable API layer, global or task-personalized.
189/// BOTH modes run the ONE authoritative service — `build_surface` (Global
190/// or Task) — so the CLI, MCP, hermes, and the SDKs render the same
191/// artifact for the same request (no parallel pipelines). The rendered
192/// entries are recorded in the context ledger.
193// trace:exempt reason=internal-detail
194// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-surface work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
195pub fn cmd_surface(
196    root: &Path,
197    task: Option<&str>,
198    budget: Option<usize>,
199    explain: bool,
200) -> crate::Result<()> {
201    let store = open_store(root)?;
202    let config = load_config(root)?;
203    let stale = crate::stale_paths(&store)?;
204    let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
205    let tokens = budget.unwrap_or(scc_core::ContextBudget::default().surface);
206    // Semantic scorer (SCC-071): wired in when the embed_cli rankers are
207    // available (inference.enabled + remote-model policy) and the surface
208    // is task-personalized (a scorer rates entities against a goal; the
209    // global surface has no goal). Disabled embeddings → None, and the
210    // pipeline explicitly redistributes the 10% semantic share.
211    let scorer = match task {
212        Some(goal) => {
213            let (scorer, _reranker) = scc_engine::inference::rankers(&store, &config, goal);
214            scorer
215        }
216        None => None,
217    };
218    let semantic: Option<&dyn scc_context::rank::SemanticScorer> =
219        scorer.as_ref().map(|s| s as &dyn scc_context::rank::SemanticScorer);
220    let req = scc_api::SurfaceRequest { task: task.map(|s| s.to_string()), budget: Some(tokens), explain, stages: None };
221    let (_result, text) = engine.context().surface(&req, semantic).map_err(engine_err)?;
222    print!("{text}");
223    Ok(())
224}
225
226/// `scc important [--limit N] [--component C] [--task G] [--json]` —
227/// the fast "where do I pay attention first" answer (audit item 3). Global
228/// mode ranks by architectural centrality (global PPR + badges); --task
229/// re-ranks by task PPR and renders TASK-CRITICAL SYMBOLS. --component
230/// filters to one component substring. No new MCP tool: the section also
231/// ships inside startup/task context.
232// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-important work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
233pub fn cmd_important(
234    root: &Path,
235    limit: usize,
236    component: Option<&str>,
237    task: Option<&str>,
238    json: bool,
239) -> crate::Result<()> {
240    let store = open_store(root)?;
241    let config = load_config(root)?;
242    let stale = crate::stale_paths(&store)?;
243    let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
244    let (entries, tasked) = engine.context().important(limit, component, task).map_err(engine_err)?;
245    if json {
246        println!("{}", serde_json::to_string_pretty(&entries)?);
247        return Ok(());
248    }
249    print!("{}", scc_context::surface::render_important(&entries, tasked));
250    Ok(())
251}
252
253/// `scc context structural --files <paths...> | --task "<goal>" [--budget N]` —
254/// the Structural Source product surface (fixwave Item 7): the per-file
255/// signature/structural representation of the requested files, or of the
256/// files matched to a task goal. Returns the rendered text so every
257/// transport (CLI, MCP) shares one implementation.
258// trace:exempt reason=internal-detail
259// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-context-structural work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
260pub fn cmd_context_structural(
261    root: &Path,
262    files: &[String],
263    task: Option<&str>,
264    budget: Option<usize>,
265) -> crate::Result<String> {
266    // Registry derivation: parse + dispatch only; the engine owns the build.
267    let out = scc_engine::invoke(
268        root,
269        "context.structural",
270        serde_json::json!({"files": files, "task": task, "budget": budget}),
271    )
272    .map_err(engine_err)?;
273    Ok(out.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string())
274}
275
276/// THE one complete task artifact (transport parity): the enriched task
277/// pack AND its surface delta derived together, from ONE builder.
278#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
279// trace:v1 id=impl.crates-scc-cli-src-commands.task-context-artifact work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching satisfies=REQ-complete-task-context-identical-across-transports
280pub struct TaskContextArtifact {
281    pub pack: scc_context::ContextPack,
282    /// The task-personalized Surface delta (only NEW relevant APIs vs the
283    /// ledger). Empty string when the delta budget is 0.
284    pub delta: String,
285    /// Entry ids the delta rendered (ledger recording). ALWAYS serialized
286    /// (empty array, not omitted) — the public JSON contract the SDKs type.
287    #[serde(default)]
288    pub delta_ids: Vec<String>,
289    /// Actual token count of the complete rendered artifact:
290    /// `estimate_tokens(pack.content) + estimate_tokens(delta)` computed
291    /// AFTER all enrichment (Beads/Hindsight), never from stale component
292    /// estimates — the accounting the SDKs and benchmarking rely on.
293    #[serde(default)]
294    pub token_count: usize,
295}
296
297/// Build the complete task artifact: scorer + beads + hindsight enrichment
298/// for the pack, then the SAME-scorer task delta against the ledger.
299/// `hook` keeps the whole focus within the §37 1500-token cap (the delta
300/// gets what the pack leaves); an explicit budget caps the total; the
301/// default gives the delta its own `task_delta` slice.
302// trace:v1 id=impl.crates-scc-cli-src-commands.build-task-context work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching satisfies=REQ-complete-task-context-identical-across-transports
303pub fn build_task_context(
304    root: &Path,
305    goal: &str,
306    files: &[String],
307    symbols: &[String],
308    budget: Option<usize>,
309    hook: bool,
310) -> crate::Result<TaskContextArtifact> {
311    build_task_context_engine(root, goal, files, symbols, budget, hook).map_err(engine_err).map(from_engine_artifact)
312}
313
314// trace:exempt reason=internal-detail
315fn from_engine_artifact(a: scc_engine::TaskContextArtifact) -> TaskContextArtifact {
316    TaskContextArtifact { pack: a.pack, delta: a.delta, delta_ids: a.delta_ids, token_count: a.token_count }
317}
318
319// trace:exempt reason=internal-detail
320fn build_task_context_engine(
321    root: &Path,
322    goal: &str,
323    files: &[String],
324    symbols: &[String],
325    budget: Option<usize>,
326    hook: bool,
327) -> scc_engine::Result<scc_engine::TaskContextArtifact> {
328    // Registry derivation: the engine owns the task build; this crate only
329    // parses args and renders. One path for CLI/HTTP/MCP/SDKs (spec 2).
330    let out = scc_engine::invoke(
331        root,
332        "context.task",
333        serde_json::json!({"goal": goal, "files": files, "symbols": symbols, "budget": budget, "hook": hook}),
334    )?;
335    serde_json::from_value(out).map_err(|e| scc_engine::EngineError::Other(e.to_string()))
336}
337
338/// pack with scorer + beads + hindsight and its post-enrichment token
339/// count. NO Surface delta, NO ContextLedger mutation, NO visibility side
340/// effects. Pack-only callers (`context compress`, [`build_task_pack`])
341/// MUST use this — NEVER [`build_task_context`], which also builds a delta
342/// and records its rendered ids in the ledger: throwing the delta away
343/// would mark Surface APIs "already visible" the agent never saw, breaking
344/// the ledger invariant (ledger = content actually shown to the agent).
345// trace:v1 id=impl.crates-scc-cli-src-commands.build-enriched-task-pack work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching satisfies=REQ-complete-task-context-identical-across-transports
346pub fn build_enriched_task_pack(
347    root: &Path,
348    goal: &str,
349    files: &[String],
350    symbols: &[String],
351    budget: Option<usize>,
352    hook: bool,
353) -> crate::Result<scc_context::ContextPack> {
354    // Registry derivation: the engine owns the pack build (spec 2).
355    let out = scc_engine::invoke(
356        root,
357        "context.task_pack",
358        serde_json::json!({"goal": goal, "files": files, "symbols": symbols, "budget": budget, "hook": hook}),
359    )
360    .map_err(engine_err)?;
361    // Engine returns the pack directly (ContextPack), not wrapped.
362    serde_json::from_value(out).map_err(|e: serde_json::Error| crate::CliError::Other(e.to_string()))
363}
364
365/// `scc context task <goal> [--budget N] [--json] [--hook]` — the complete
366/// task focus. Text and JSON are TWO VIEWS OF ONE ARTIFACT built by
367/// [`build_task_context`]: JSON carries `{pack, delta, delta_ids}`, text
368/// prints pack content followed by the delta — identical derivation, no
369/// transport downgrades quality.
370// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-context-task work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching satisfies=REQ-complete-task-context-identical-across-transports
371pub fn cmd_context_task(
372    root: &Path,
373    goal: &str,
374    files: &[String],
375    symbols: &[String],
376    budget: Option<usize>,
377    json: bool,
378    hook: bool,
379) -> crate::Result<()> {
380    if hook {
381        // Wave 2 (§37): UserPromptSubmit injects a task focus only when
382        // context.inject_task_focus is enabled; otherwise silent no-op.
383        let config = load_config(root)?;
384        if !config.context.inject_task_focus {
385            return Ok(());
386        }
387    }
388    let artifact = build_task_context(root, goal, files, symbols, budget, hook)?;
389    if json {
390        println!("{}", serde_json::to_string_pretty(&artifact)?);
391    } else {
392        print!("{}", artifact.pack.content);
393        print!("\n{}", artifact.delta);
394    }
395    Ok(())
396}
397
398
399/// half built by the pure [`build_enriched_task_pack`] — NO delta, NO
400/// ledger side effects. New transports should call
401/// [`build_task_context`] directly so the delta ships with the pack.
402// trace:exempt reason=internal-detail
403pub fn build_task_pack(
404    root: &Path,
405    goal: &str,
406    files: &[String],
407    symbols: &[String],
408    budget: Option<usize>,
409) -> crate::Result<scc_context::ContextPack> {
410    build_enriched_task_pack(root, goal, files, symbols, budget, false)
411}
412/// The complete task artifact as JSON — the same derivation as CLI text
413/// (`{pack, delta, delta_ids}`); serialization is the only difference.
414// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-context-task-json work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching satisfies=REQ-complete-task-context-identical-across-transports
415pub fn cmd_context_task_json(
416    root: &Path,
417    goal: &str,
418    files: &[String],
419    symbols: &[String],
420    budget: Option<usize>,
421) -> crate::Result<String> {
422    Ok(serde_json::to_string_pretty(&build_task_context(
423        root, goal, files, symbols, budget, false,
424    )?)?)
425}
426
427/// `scc context docs <dependency>` — external library docs via Context7
428/// (labeled external; never mixed with repository facts).
429// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-context-docs work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
430pub fn cmd_context_docs(root: &Path, dependency: &str) -> crate::Result<()> {
431    print!("{}", scc_engine::invoke(root, "context.external_docs", serde_json::json!({"dependency": dependency})).map_err(engine_err)?);
432    Ok(())
433}
434
435/// Subagent context policy (SCC-107, docs/API_AND_INTEGRATIONS.md §5):
436/// a narrower, tighter-budget task pack with explicit scope boundaries so
437/// delegated agents start from the same system model without re-deriving it.
438// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-context-subagent work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
439pub fn cmd_context_subagent(
440    root: &Path,
441    goal: &str,
442    files: &[String],
443    symbols: &[String],
444    budget: Option<usize>,
445    json: bool,
446) -> crate::Result<()> {
447    let store = open_store(root)?;
448    let config = load_config(root)?;
449    let stale = crate::stale_paths(&store)?;
450    let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
451    let pack = engine.context().subagent(goal, files, symbols, budget).map_err(engine_err)?;
452    if json {
453        println!("{}", serde_json::to_string_pretty(&pack)?);
454    } else {
455        print!("{}", pack.content);
456    }
457    Ok(())
458}
459
460// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-context-component work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
461pub fn cmd_context_component(root: &Path, id: &str, json: bool, unbounded: bool) -> crate::Result<()> {
462    let store = open_store(root)?;
463    let config = load_config(root)?;
464    let stale = crate::stale_paths(&store)?;
465    let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
466    let pack = engine.context().component(&scc_api::DetailRequest { id: id.into(), unbounded }).map_err(engine_err)?;
467    if json {
468        println!("{}", serde_json::to_string_pretty(&pack)?);
469    } else {
470        print!("{}", pack.content);
471    }
472    Ok(())
473}
474
475// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-context-flow work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
476pub fn cmd_context_flow(root: &Path, id: &str, json: bool, unbounded: bool) -> crate::Result<()> {
477    let store = open_store(root)?;
478    let config = load_config(root)?;
479    let stale = crate::stale_paths(&store)?;
480    let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
481    let pack = engine.context().flow(&scc_api::DetailRequest { id: id.into(), unbounded }).map_err(engine_err)?;
482    if json {
483        println!("{}", serde_json::to_string_pretty(&pack)?);
484    } else {
485        print!("{}", pack.content);
486    }
487    Ok(())
488}
489
490// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-impact work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
491pub fn cmd_impact(
492    root: &Path,
493    files: &[String],
494    symbols: &[String],
495    diff: Option<&str>,
496    json: bool,
497    unbounded: bool,
498) -> crate::Result<()> {
499    let store = open_store(root)?;
500    let config = load_config(root)?;
501    let stale = crate::stale_paths(&store)?;
502    let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
503    let pack = engine.context().impact(&scc_api::ImpactRequest { files: files.to_vec(), symbols: symbols.to_vec(), diff: diff.map(|s| s.to_string()), unbounded }).map_err(engine_err)?;
504    if json {
505        println!("{}", serde_json::to_string_pretty(&pack)?);
506    } else {
507        print!("{}", pack.content);
508    }
509    Ok(())
510}
511
512// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-verify work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
513pub fn cmd_verify(
514    root: &Path,
515    warnings_only: bool,
516    json: bool,
517    unbounded: bool,
518) -> crate::Result<()> {
519    let store = open_store(root)?;
520    let config = load_config(root)?;
521    let stale = crate::stale_paths(&store)?;
522    let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
523    let pack = engine.context().verify(unbounded).map_err(engine_err)?;
524    if warnings_only {
525        for w in &pack.warnings {
526            println!("⚠ {w}");
527        }
528        return Ok(());
529    }
530    if json {
531        println!("{}", serde_json::to_string_pretty(&pack)?);
532        return Ok(());
533    }
534    print!("{}", pack.content);
535    Ok(())
536}
537
538// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-drift work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
539pub fn cmd_drift(root: &Path, json: bool) -> crate::Result<()> {
540    let store = open_store(root)?;
541    let findings = scc_engine::misc::drift(&store).map_err(engine_err)?;
542    if json {
543        println!("{}", serde_json::to_string_pretty(&findings)?);
544    } else {
545        if findings.is_empty() {
546            println!("no drift findings");
547        }
548        for f in &findings {
549            println!("[{0}] {1} (#{2}, {3}): {4}", f.severity, f.kind, f.id, f.created_at, f.message);
550        }
551    }
552    Ok(())
553}
554
555// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-system work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
556pub fn cmd_system(_root: &Path, members: &[std::path::PathBuf], json: bool) -> crate::Result<()> {
557    let (members, all) = scc_engine::systems::stitch(members).map_err(engine_err)?;
558    if json {
559        println!("{}", serde_json::to_string_pretty(&all)?);
560        return Ok(());
561    }
562    println!(
563        "system: {} members, {} stitches",
564        members.len(),
565        all.len()
566    );
567    for m in &members {
568        println!("member {} ({})", m.repo_id, m.root.display());
569    }
570    for s in &all {
571        println!(
572            "[{:?}/{:?}] {} ({} ends)",
573            s.kind,
574            s.match_kind,
575            s.key,
576            s.ends.len()
577        );
578        for e in &s.ends {
579            match e.role {
580                Some(role) => println!("  {} {} ({role:?})", e.repo_id, e.entity_id),
581                None => println!("  {} {}", e.repo_id, e.entity_id),
582            }
583        }
584    }
585    Ok(())
586}
587
588// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-history work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
589pub fn cmd_history(root: &Path, json: bool) -> crate::Result<()> {
590    let store = open_store(root)?;
591    let revs = scc_engine::history::revisions(&store).map_err(engine_err)?;
592    if json {
593        println!("{}", serde_json::to_string_pretty(&revs)?);
594        return Ok(());
595    }
596    if revs.is_empty() {
597        println!("no graph revisions recorded");
598        return Ok(());
599    }
600    for r in &revs {
601        println!(
602            "rev {} (base {}): {} entities, {} rels, {} files | src={} ext={} | {}",
603            r.rev,
604            r.base_rev,
605            r.entity_count,
606            r.rel_count,
607            r.file_count,
608            &r.source_hash[..r.source_hash.len().min(12)],
609            r.extractor_version,
610            r.created_at,
611        );
612    }
613    Ok(())
614}
615
616// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-diff work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
617pub fn cmd_diff(root: &Path, from: i64, to: i64, json: bool) -> crate::Result<()> {
618    let store = open_store(root)?;
619    let d = scc_engine::history::diff(&store, from, to).map_err(engine_err)?;
620    if json {
621        println!("{}", serde_json::to_string_pretty(&d)?);
622        return Ok(());
623    }
624    // Bounded human rendering: counts plus the first 20 ids per class.
625    println!("diff rev {from}..{to}:");
626    for (label, ids) in [
627        ("added entities", &d.added_entities),
628        ("removed entities", &d.removed_entities),
629        ("modified entities", &d.modified_entities),
630        ("added relationships", &d.added_relationships),
631        ("removed relationships", &d.removed_relationships),
632        ("modified relationships", &d.modified_relationships),
633    ] {
634        println!("  {label}: {}", ids.len());
635        for id in ids.iter().take(20) {
636            println!("    {id}");
637        }
638        if ids.len() > 20 {
639            println!("    … ({} more)", ids.len() - 20);
640        }
641    }
642    if !d.modified_kinds.is_empty() {
643        let kinds: Vec<String> = d
644            .modified_kinds
645            .iter()
646            .map(|(k, v)| format!("{k}:{v}"))
647            .collect();
648        println!("  modified kinds: {}", kinds.join(", "));
649    }
650    Ok(())
651}
652
653// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-snapshot-save work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
654pub fn cmd_snapshot_save(
655    root: &Path,
656    task: &str,
657    budget: Option<usize>,
658    json: bool,
659) -> crate::Result<()> {
660    let snap = scc_engine::misc::snapshot_save(root, task, budget).map_err(engine_err)?;
661    if json {
662        println!("{}", serde_json::to_string_pretty(&snap)?);
663    } else {
664        println!("snapshot {} ({} visible ids)", snap.id, snap.entity_ids.len());
665    }
666    Ok(())
667}
668
669// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-snapshot-show work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
670pub fn cmd_snapshot_show(root: &Path, id: &str) -> crate::Result<()> {
671    let store = open_store(root)?;
672    match scc_engine::snapshots::get(&store, id).map_err(engine_err)? {
673        Some(s) => print!("{}", s.artifact),
674        None => println!("no snapshot {id}"),
675    }
676    Ok(())
677}
678
679// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-snapshot-diff work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
680pub fn cmd_snapshot_diff(root: &Path, id: &str, json: bool) -> crate::Result<()> {
681    let store = open_store(root)?;
682    match scc_engine::snapshots::diff(&store, id).map_err(engine_err)? {
683        Some(d) => {
684            if json {
685                println!("{}", serde_json::to_string_pretty(&d)?);
686            } else {
687                println!(
688                    "snapshot rev {} vs current rev {}: {} still valid, {} invalidated, {} modified{}",
689                    d.snapshot_revision,
690                    d.current_revision,
691                    d.still_valid.len(),
692                    d.invalidated.len(),
693                    d.modified_entities.len(),
694                    if d.artifact_changed { " (artifact would re-render differently)" } else { "" },
695                );
696                for (label, ids) in [
697                    ("invalidated", &d.invalidated),
698                    ("modified entities", &d.modified_entities),
699                    ("changed relationships", &d.changed_relationships),
700                    ("changed contracts", &d.changed_contracts),
701                    ("changed state", &d.changed_state),
702                    ("changed flows", &d.changed_flows),
703                ] {
704                    if ids.is_empty() {
705                        continue;
706                    }
707                    println!("  {label} ({}):", ids.len());
708                    for f in ids.iter().take(20) {
709                        println!("    - {f}");
710                    }
711                    if ids.len() > 20 {
712                        println!("    … ({} more)", ids.len() - 20);
713                    }
714                }
715            }
716        }
717        None => println!("no snapshot {id}"),
718    }
719    Ok(())
720}
721
722// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-export work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
723pub fn cmd_export(root: &Path, format: &str) -> crate::Result<()> {
724    let store = open_store(root)?;
725    match format {
726        "system-ir.json" => println!("{}", serde_json::to_string_pretty(&scc_engine::exports::system_ir(&store).map_err(engine_err)?)?),
727        "system-ir.jsonl" => {
728            let ir = scc_engine::exports::system_ir(&store).map_err(engine_err)?;
729            for line in scc_engine::exports::jsonl(&ir).map_err(engine_err)? {
730                println!("{line}");
731            }
732        }
733        "ccg" => println!("{}", serde_json::to_string_pretty(&scc_engine::exports::ccg(&scc_engine::exports::system_ir(&store).map_err(engine_err)?).map_err(engine_err)?)?),
734        "flow-graphs.json" => {
735            println!(
736                "{}",
737                serde_json::to_string_pretty(&store.flow_graphs()?)?
738            )
739        }
740        "capsule.md" => print!("{}", scc_engine::exports::capsule(root).map_err(engine_err)?),
741        other => {
742            return Err(crate::CliError::Other(format!(
743                "unknown export format '{other}' (use system-ir.json, system-ir.jsonl, ccg, or capsule.md)"
744            )))
745        }
746    }
747    Ok(())
748}
749
750// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-query work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
751// trace:v1 id=impl.cli.query.fallback work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
752pub fn cmd_query(root: &Path, query: &str, limit: usize) -> crate::Result<()> {
753    let store = open_store(root)?;
754    let hit = scc_engine::graph::query(&store, &scc_api::QueryRequest { query: query.into(), limit }).map_err(engine_err)?;
755    println!("— entities —");
756    for e in &hit.entities {
757        println!("{} [{}]", e.name, e.kind);
758    }
759    println!("— symbols —");
760    for (name, sig, kind, file, line) in &hit.symbols {
761        if *line > 0 {
762            println!("{name} ({kind}) {file}:{line} {sig}");
763        } else {
764            println!("{name} ({kind}) {file} {sig}");
765        }
766    }
767    Ok(())
768}
769
770// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-list-components work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
771pub fn cmd_list_components(root: &Path) -> crate::Result<()> {
772    let store = open_store(root)?;
773    for c in scc_engine::graph::components(&store).map_err(engine_err)? {
774        println!("{}", c.name);
775    }
776    Ok(())
777}
778
779// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-list-flows work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
780pub fn cmd_list_flows(root: &Path) -> crate::Result<()> {
781    let store = open_store(root)?;
782    for f in scc_engine::graph::flows(&store).map_err(engine_err)? {
783        println!("{} [{}] {}", f.name, crate::flow_kind_str(&f.kind), f.trigger.unwrap_or_default());
784    }
785    Ok(())
786}
787
788/// `scc cochange`: print the git co-change pairs (files changed together
789/// across commits) and, when an indexed store exists, enrich its components
790/// with the signal.
791// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-cochange work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
792pub fn cmd_cochange(root: &Path, min_commits: u32) -> crate::Result<()> {
793    let (pairs, n) = scc_engine::misc::cochange(root, min_commits).map_err(engine_err)?;
794    if pairs.is_empty() {
795        println!("no co-change pairs with >= {min_commits} shared commits");
796    } else {
797        println!("co-change pairs (>= {min_commits} shared commits):");
798        for p in &pairs {
799            println!("  {} <-> {} ×{}", p.a, p.b, p.commits);
800        }
801    }
802    if n > 0 {
803        println!("enriched {n} components with co-change signal");
804    }
805    Ok(())
806}
807
808/// scc verify --graph-invariants: structural checks for CI.
809// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-check-invariants work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
810pub fn cmd_check_invariants(root: &Path) -> crate::Result<bool> {
811    let store = open_store(root)?;
812    let violations = scc_engine::misc::check_invariants(&store).map_err(engine_err)?;
813    for v in &violations {
814        println!("{}", v.message);
815    }
816    Ok(violations.is_empty())
817}
818
819/// `scc ci check` (docs/DEPLOYMENT_AND_INFRA.md §3, EPIC-180 CI policies):
820/// graph invariants + drift severity policy. Exits nonzero on violation.
821// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-ci-check work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
822pub fn cmd_ci_check(root: &Path, max_severity: &str) -> crate::Result<bool> {
823    let store = open_store(root)?;
824    let violations = scc_engine::misc::check_invariants(&store).map_err(engine_err)?;
825    for v in &violations {
826        println!("{}", v.message);
827    }
828    let (ok, lines) = scc_engine::misc::ci_check(&store, &violations, max_severity).map_err(engine_err)?;
829    for l in &lines {
830        // invariant lines already printed above; print only ci lines + summary
831        if l.starts_with("[ci:") || *l == "ci check passed" {
832            println!("{l}");
833        }
834    }
835    Ok(ok)
836}
837
838// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-checkpoint-save work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
839pub fn cmd_checkpoint_save(root: &Path, json: bool) -> crate::Result<()> {
840    let data = scc_engine::checkpoint::capture(root).map_err(engine_err)?;
841    if json {
842        println!("{}", serde_json::to_string(&data)?);
843    } else {
844        println!("checkpoint saved to {}", crate::checkpoint_path(root).display());
845    }
846    Ok(())
847}
848
849// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-checkpoint-load work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
850pub fn cmd_checkpoint_load(root: &Path, inject: bool) -> crate::Result<()> {
851    if let Some(content) = scc_engine::checkpoint::load(root).map_err(engine_err)? {
852        print!("{content}");
853    } else if !inject {
854        println!("no checkpoint found");
855    }
856    Ok(())
857}
858
859// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-watch work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
860pub fn cmd_watch(root: &Path) -> crate::Result<()> {
861    crate::httpd::watch_loop(root)
862}
863
864// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-serve work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
865pub fn cmd_serve(root: &Path) -> crate::Result<()> {
866    crate::httpd::serve(root)
867}
868
869// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-mcp work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
870pub fn cmd_mcp(root: &Path) -> crate::Result<()> {
871    crate::mcp::serve_stdio(root)
872}
873
874// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-setup-claude work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
875pub fn cmd_setup_claude(root: &Path) -> crate::Result<()> {
876    crate::plugin::install(root)
877}
878
879// Setup targets for harness auto-detection. Hermes is deliberately absent:
880// it installs into a home directory outside the repo (different trust
881// domain), so it stays an explicit opt-in.
882#[derive(Debug, Clone, Copy, PartialEq, Eq)]
883// trace:exempt reason=internal-detail
884pub enum SetupHarness {
885    Claude,
886    Codex,
887    Opencode,
888    Omp,
889    Pi,
890}
891
892// Pure detection: a harness counts as present when its binary is on PATH
893// or its home/config dir exists (or, for project-local OMP/Pi, the repo
894// already carries the harness dir). `path_dirs` is PATH split already so
895// tests never touch the process environment.
896// trace:exempt reason=internal-detail
897pub fn detect_harnesses(home: &Path, path_dirs: &[PathBuf], root: &Path) -> Vec<SetupHarness> {
898    let on_path = |bin: &str| {
899        path_dirs
900            .iter()
901            .any(|d| d.join(bin).is_file() || d.join(format!("{bin}.exe")).is_file())
902    };
903    let mut out = Vec::new();
904    if on_path("claude") || home.join(".claude").is_dir() {
905        out.push(SetupHarness::Claude);
906    }
907    if on_path("codex") || home.join(".codex").is_dir() {
908        out.push(SetupHarness::Codex);
909    }
910    if on_path("opencode") || home.join(".config").join("opencode").is_dir() {
911        out.push(SetupHarness::Opencode);
912    }
913    if on_path("omp") || root.join(".omp").is_dir() {
914        out.push(SetupHarness::Omp);
915    }
916    if on_path("pi") || root.join(".pi").is_dir() || home.join(".pi").is_dir() {
917        out.push(SetupHarness::Pi);
918    }
919    out
920}
921
922// trace:exempt reason=internal-detail
923fn install_harness(root: &Path, h: SetupHarness) -> crate::Result<()> {
924    match h {
925        SetupHarness::Claude => cmd_setup_claude(root),
926        SetupHarness::Codex => crate::compress::cmd_setup_codex(root),
927        SetupHarness::Opencode => crate::compress::cmd_setup_opencode(root),
928        SetupHarness::Omp => crate::plugin_omp::cmd_setup_omp(root),
929        SetupHarness::Pi => crate::plugin_omp::cmd_setup_pi(root),
930    }
931}
932
933/// `scc setup` (no subcommand): install for every detected harness and
934/// print one summary, including the manual steps setup cannot perform
935/// (Codex hooks live in user scope). `scc setup all` skips detection and
936/// installs for every harness unconditionally.
937// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-setup-detected work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
938pub fn cmd_setup_detected(root: &Path, all: bool) -> crate::Result<()> {
939    let targets: Vec<SetupHarness> = if all {
940        vec![
941            SetupHarness::Claude,
942            SetupHarness::Codex,
943            SetupHarness::Opencode,
944            SetupHarness::Omp,
945            SetupHarness::Pi,
946        ]
947    } else {
948        let home = std::env::var_os("HOME")
949            .map(PathBuf::from)
950            .unwrap_or_else(|| PathBuf::from("/"));
951        let path_dirs: Vec<PathBuf> = std::env::var_os("PATH")
952            .map(|p| std::env::split_paths(&p).collect())
953            .unwrap_or_default();
954        detect_harnesses(&home, &path_dirs, root)
955    };
956    if targets.is_empty() {
957        println!("No supported harness detected (looked for claude/codex/opencode/omp/pi");
958        println!("binaries, ~/.claude, ~/.codex, ~/.config/opencode, ~/.pi, and .omp//.pi/ dirs).");
959        println!("Run `scc setup all` to install for every harness, or `scc setup <harness>`.");
960        return Ok(());
961    }
962    for h in &targets {
963        println!("=== {:?} ===", h);
964        install_harness(root, *h)?;
965        println!();
966    }
967    println!("Installed for: {}", targets.iter().map(|h| format!("{h:?}")).collect::<Vec<_>>().join(", "));
968    if targets.contains(&SetupHarness::Codex) {
969        println!("Remaining manual step: add the printed entry to ~/.codex/hooks.json (user scope).");
970    }
971    Ok(())
972}
973
974// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-ingest-runtime work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
975pub fn cmd_ingest_runtime(root: &Path, body: &str) -> crate::Result<()> {
976    scc_engine::state::ingest_runtime(root, body).map_err(engine_err)?;
977    println!("accepted");
978    Ok(())
979}
980
981/// Declared capability scope per adapter (docs/SECURITY.md §6: the doc
982/// defines the manifest dimensions — FS scope, network, subprocess,
983/// credentials — but has no per-adapter table, so the assignments are
984/// pinned inline here). Importers read repo-local files only; Context7 runs
985/// an MCP server over stdio via npx (subprocess) that makes network calls.
986// trace:v1 id=impl.crates-scc-cli-src-commands.adapter-scope work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
987fn adapter_scope(name: &str) -> &'static str {
988    match name {
989        "context7" => "network+subprocess(npx)",
990        "serena" | "beads" | "cbm" | "hindsight" | "scip" | "gitnexus" | "narsil" => "filesystem",
991        _ => "filesystem",
992    }
993}
994
995/// Compute the configured-adapter scope listing from THE Integration
996/// Registry: every row whose config key is enabled (or needs no config for
997/// on-demand import), in fixed registry order. `narsil` never appears: it is
998/// a `ccg` alias, resolved by `resolve_integration`. Both `scc adapters`
999/// views read one registry, so they describe one universe.
1000// trace:v1 id=impl.crates-scc-cli-src-commands.configured-adapters work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1001fn configured_adapters(root: &Path) -> crate::Result<Vec<(String, &'static str)>> {
1002    use scc_indexer::adapters::IntegrationCategory;
1003    let config = load_config(root)?;
1004    let enabled = |key: Option<&str>| -> bool {
1005        match key {
1006            None => true,
1007            Some("serena") => config.integrations.serena,
1008            Some("beads") => config.integrations.beads,
1009            Some("hindsight") => config.integrations.hindsight,
1010            Some("gitnexus") => config.integrations.gitnexus,
1011            Some("context7_command") => !config.integrations.context7_command.is_empty(),
1012            // Unknown keys fail closed: a new config flag must be wired
1013            // here explicitly, never silently treated as enabled.
1014            Some(_) => false,
1015        }
1016    };
1017    Ok(scc_indexer::adapters::integration_registry()
1018        .into_iter()
1019        .filter(|d| {
1020            d.category != IntegrationCategory::CompatibilityOnly
1021                && d.category != IntegrationCategory::InternalPass
1022                && d.category != IntegrationCategory::AgentIntegration
1023                && enabled(d.config_key)
1024        })
1025        .map(|d| (d.id.to_string(), adapter_scope(d.id)))
1026        .collect())
1027}
1028
1029/// `scc adapters` — list enabled adapters with their declared capability
1030/// scope (security audit; docs/SECURITY.md §6). `--json` dumps the full
1031/// capability manifests instead.
1032// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-adapters work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
1033pub fn cmd_adapters(root: &Path, json: bool) -> crate::Result<()> {
1034    let manifests = scc_indexer::adapters::adapter_manifests();
1035    if json {
1036        println!("{}", serde_json::to_string_pretty(&manifests)?);
1037        return Ok(());
1038    }
1039    for (name, scope) in configured_adapters(root)? {
1040        println!("adapter: {name}  scope: {scope}");
1041    }
1042    Ok(())
1043}
1044
1045/// `scc doctor` — integration health from THE Integration Registry
1046/// (`scc_indexer::adapters::integration_registry`), offline, read-only and
1047/// fast. Default never touches the network and never spawns a subprocess:
1048/// every row is answered from local state only (config flags, binary on
1049/// PATH, files present, evidence already in the graph). `--deep` may start
1050/// LOCAL subprocesses (pyright/tsserver handshakes); `--network` may test
1051/// remote endpoints (Context7). `--strict` turns warnings into a nonzero
1052/// exit; `--json` emits the machine-readable report.
1053///
1054/// Honesty rule: a row reports IMPLEMENTED / CONFIGURED / REACHABLE /
1055/// CONTRIBUTING separately. A config boolean never implies contribution —
1056/// contribution means facts of that integration's evidence kind are actually
1057/// in the graph (or a handshake succeeded under --deep/--network).
1058// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-doctor work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1059pub fn cmd_doctor(root: &Path, json: bool, deep: bool, network: bool, strict: bool) -> crate::Result<bool> {
1060    let store = open_store(root)?;
1061    let config = load_config(root)?;
1062    let rep = scc_engine::integrations::doctor_report(&store, &config, root, deep, network).map_err(engine_err)?;
1063    let rows = rep.integrations;
1064    let agent_rows = rep.agents;
1065    let mut warnings = rep.warnings;
1066    let (scc_version, rev, stale_len, dangling, stats) =
1067        (rep.scc_version, rep.revision, rep.stale_files, rep.dangling_edges, rep.stats);
1068    if json {
1069        println!(
1070            "{}",
1071            serde_json::to_string_pretty(&serde_json::json!({
1072                "scc": scc_version,
1073                "revision": rev,
1074                "stale_files": stale_len,
1075                "dangling_edges": dangling,
1076                "stats": stats,
1077                "integrations": rows,
1078                "agents": agent_rows.iter().map(|a| serde_json::json!({"id": a.id, "present": a.present, "detail": a.detail})).collect::<Vec<_>>(),
1079                "warnings": warnings,
1080            }))?
1081        );
1082    } else {
1083        println!("SCC Doctor");
1084        println!("==========");
1085        println!();
1086        println!("CORE");
1087        println!("  scc        {scc_version}");
1088        println!("  revision   {rev}");
1089        println!("  stale      {} file(s)", stale_len);
1090        println!("  dangling   {dangling} edge(s) (see `scc check-invariants`)");
1091        println!("  entities   {}", stats.get("entities").copied().unwrap_or(0));
1092        println!();
1093        println!("INTEGRATIONS");
1094        for r in &rows {
1095            let mark = if r.contributing {
1096                "✓"
1097            } else if r.configured {
1098                "!"
1099            } else {
1100                "-"
1101            };
1102            let reach = match r.reachable {
1103                Some(true) => "reachable; ",
1104                Some(false) => "UNREACHABLE; ",
1105                None => "",
1106            };
1107            println!("  {mark} {:<12} [{:<18}] {}{}", r.id, r.category, reach, r.detail);
1108        }
1109        println!();
1110        println!("AGENTS");
1111        for a in &agent_rows {
1112            println!("  {} {:<12} {}", if a.present { "✓" } else { "-" }, a.id, a.detail);
1113        }
1114        println!();
1115        if warnings == 0 {
1116            println!("SUMMARY  PASS ({} integrations contributing evidence)", rows.iter().filter(|r| r.contributing).count());
1117        } else {
1118            println!("SUMMARY  {warnings} warning(s); run `scc doctor --deep` / `--network` for handshake detail");
1119        }
1120    }
1121    if dangling > 0 {
1122        warnings += 1;
1123    }
1124    // `scc check-invariants` remains the CI gate for graph integrity;
1125    // doctor reports health (warnings) by default and fails only on
1126    // --strict, so informational runs stay exit-0.
1127    Ok(!(strict && warnings > 0))
1128}
1129
1130/// `scc lessons add <text>` — append one durable lesson to
1131/// `<root>/.scc/lessons.jsonl` (the Hindsight memory bank). The bank is
1132/// ingested into the System IR with `scc import hindsight .scc/lessons.jsonl`.
1133// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-lessons-add work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
1134pub fn cmd_lessons_add(root: &Path, text: &str) -> crate::Result<()> {
1135    let dir = scc_dir(root);
1136    std::fs::create_dir_all(&dir)?;
1137    let path = dir.join("lessons.jsonl");
1138    let n = std::fs::read_to_string(&path)
1139        .map(|s| s.lines().filter(|l| !l.trim().is_empty()).count())
1140        .unwrap_or(0);
1141    let id = format!("lesson-{}", n + 1);
1142    let record = serde_json::json!({
1143        "id": id,
1144        "text": text,
1145        "created_at": scc_core::now_rfc3339(),
1146    });
1147    let mut f = std::fs::OpenOptions::new()
1148        .create(true)
1149        .append(true)
1150        .open(&path)
1151        .map_err(|e| crate::CliError::Other(format!("lessons: {e}")))?;
1152    writeln!(f, "{record}").map_err(|e| crate::CliError::Other(format!("lessons: {e}")))?;
1153    println!(
1154        "appended {id} to {} (ingest with `scc import hindsight .scc/lessons.jsonl`)",
1155        path.display()
1156    );
1157    Ok(())
1158}
1159
1160/// `scc lessons` — list stored lessons from the System IR (most important
1161/// first, same ordering as the context-pack enrichment).
1162// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-lessons-list work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
1163pub fn cmd_lessons_list(root: &Path, limit: usize) -> crate::Result<()> {
1164    let lessons = scc_engine::state::lessons_list(root, limit).map_err(engine_err)?;
1165    if lessons.is_empty() {
1166        println!("no lessons in the store — run `scc lessons add \"...\"` then `scc import hindsight .scc/lessons.jsonl`");
1167        return Ok(());
1168    }
1169    for (content, tags) in lessons {
1170        let tag_str = if tags.is_empty() {
1171            String::new()
1172        } else {
1173            format!(" [{}]", tags.join(", "))
1174        };
1175        println!("- {content}{tag_str}");
1176    }
1177    Ok(())
1178}
1179
1180/// `scc beads` — list active (in-progress) tasks from `.beads/issues.jsonl`
1181/// (task state, not system facts).
1182// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-beads work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
1183pub fn cmd_beads(root: &Path) -> crate::Result<()> {
1184    let active = scc_engine::state::beads(root, 20).map_err(engine_err)?;
1185    if active.is_empty() {
1186        println!("no active beads tasks (checked .beads/issues.jsonl)");
1187        return Ok(());
1188    }
1189    println!("active beads tasks:");
1190    for t in active {
1191        println!("- {t}");
1192    }
1193    Ok(())
1194}
1195
1196// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-import work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
1197pub fn cmd_import(root: &Path, format: &str, file: &str) -> crate::Result<()> {
1198    let report = scc_engine::state::import_evidence(root, format, file).map_err(engine_err)?;
1199    // P0: imported evidence changes system truth — the engine already bumped
1200    // the evidence epoch and recompiled the derived layer.
1201    println!(
1202        "imported {} symbols, {} calls, {} imports ({} errors); evidence epoch bumped, derived layer recompiled",
1203        report.symbols, report.calls, report.imports, report.errors
1204    );
1205    Ok(())
1206}
1207
1208// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-runtime-status work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
1209pub fn cmd_runtime_status(root: &Path, json: bool) -> crate::Result<()> {
1210    let edges = scc_engine::state::runtime_edges(root).map_err(engine_err)?;
1211    if json {
1212        println!("{}", serde_json::to_string_pretty(&edges)?);
1213        return Ok(());
1214    }
1215    if edges.is_empty() {
1216        println!("no runtime observations ingested");
1217    }
1218    let total: u64 = edges.iter().map(|e| e.count).sum();
1219    let errs: u64 = edges.iter().map(|e| e.errors).sum();
1220    println!("{} observed edge(s), {} observations, {} error(s)", edges.len(), total, errs);
1221    for e in &edges {
1222        println!(
1223            "- {} → {} ×{} (avg {:.1} ms, {} err, last {})",
1224            e.source, e.target, e.count, e.latency_ms, e.errors, e.last_observed
1225        );
1226    }
1227    Ok(())
1228}
1229
1230// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-runtime-reconcile work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
1231pub fn cmd_runtime_reconcile(root: &Path, json: bool) -> crate::Result<()> {
1232    let rec = scc_engine::state::reconcile(root).map_err(engine_err)?;
1233    if json {
1234        println!("{}", serde_json::to_string_pretty(&rec)?);
1235        return Ok(());
1236    }
1237    println!("static-vs-observed reconciliation");
1238    println!("  matched:             {}", rec.matched.len());
1239    println!("  observed not static: {}", rec.observed_not_static.len());
1240    println!("  static not observed: {}", rec.static_not_observed.len());
1241    for e in &rec.matched {
1242        println!("  [matched] {e}");
1243    }
1244    for e in &rec.observed_not_static {
1245        println!("  [runtime-only] {e}");
1246    }
1247    for e in &rec.static_not_observed {
1248        println!("  [static-only] {e}");
1249    }
1250    Ok(())
1251}
1252
1253/// `scc diagram` — SCC-native architecture diagram (SPEC-SCC-VIEWER §1).
1254/// L1 nodes plus capped architectural edges plus flow subgraphs, as Mermaid
1255/// (default) or dependency-free SVG. Deterministic: same index, same bytes.
1256// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-diagram work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
1257pub fn cmd_diagram(root: &Path, format: &str, out: Option<&str>) -> crate::Result<()> {
1258    let store = open_store(root)?;
1259    if store.snapshot_status()?.is_none() {
1260        return Err(crate::CliError::Other("not indexed yet — run `scc index`".into()));
1261    }
1262    // Registry derivation: the engine owns the model + rendering; the CLI
1263    // parses args, writes files, and prints (spec section 2).
1264    let v = scc_engine::invoke(root, "export.diagram", serde_json::json!({"format": format}))
1265        .map_err(engine_err)?;
1266    let text = v.get("text").and_then(|t| t.as_str()).unwrap_or("");
1267    if let Some(path) = out {
1268        std::fs::write(path, text)?;
1269        println!(
1270            "diagram: {} nodes, {} edges, {} flows -> {path}",
1271            v.get("nodes").and_then(|n| n.as_u64()).unwrap_or(0),
1272            v.get("edges").and_then(|n| n.as_u64()).unwrap_or(0),
1273            v.get("flows").and_then(|n| n.as_u64()).unwrap_or(0),
1274        );
1275    } else {
1276        print!("{text}");
1277    }
1278    Ok(())
1279}
1280
1281/// `scc snap` — Snapcompact-style bitmap export (SPEC-SCC-VIEWER §3).
1282/// Default OFF: requires `--out` text plus `--png` (or config
1283/// `context.snap_enabled`) to render; otherwise prints the map text and the
1284/// pinned Pillow recipe so the operator sees exactly what would ship.
1285// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-snap work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
1286pub fn cmd_snap(
1287    root: &Path,
1288    out: Option<&str>,
1289    max_chars: usize,
1290    png: Option<&str>,
1291    color: bool,
1292) -> crate::Result<()> {
1293    let store = open_store(root)?;
1294    if store.snapshot_status()?.is_none() {
1295        return Err(crate::CliError::Other("not indexed yet — run `scc index`".into()));
1296    }
1297    let config = load_config(root)?;
1298    let map = crate::viewer::map_text(&store, max_chars)?;
1299    let rows = map.lines().count();
1300    let (text_tokens, image_tokens) = crate::viewer::token_estimate(map.len(), rows);
1301    let render = png.is_some() || config.context.snap_enabled;
1302    if let Some(path) = out {
1303        std::fs::write(path, &map)?;
1304    } else {
1305        print!("{map}");
1306        if !map.ends_with('\n') {
1307            println!();
1308        }
1309    }
1310    if color {
1311        eprintln!("note: --color is accepted for recipe parity; the pinned recipe renders monochrome unless edited");
1312    }
1313    println!("snap: {rows} rows, {} chars — text ~{text_tokens} tokens vs PNG ~{image_tokens} image tokens", map.len());
1314    if render {
1315        let dest = png.unwrap_or("scc-snap.png");
1316        render_snap_png(&map, dest)?;
1317        println!("snap png -> {dest}");
1318    } else {
1319        println!("render OFF by default (set context.snap_enabled=true or pass --png <file>); recipe:");
1320        println!("{}", crate::viewer::snap_recipe());
1321    }
1322    Ok(())
1323}
1324
1325/// Render the map text to PNG via the pinned Pillow recipe. Shells out to
1326/// `python3` only when rendering was explicitly requested; falls back to
1327/// printing the recipe path when Pillow is missing.
1328// trace:v1 id=impl.crates-scc-cli-src-commands.render-snap-png work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
1329fn render_snap_png(map: &str, dest: &str) -> crate::Result<()> {
1330    // Fail fast with an actionable error when Pillow is missing (CI has no
1331    // PIL): the operator installs Pillow or uses the printed recipe.
1332    let probe = std::process::Command::new("python3")
1333        .arg("-c")
1334        .arg("import PIL")
1335        .output()
1336        .map_err(|e| crate::CliError::Other(format!("snap: cannot run python3 ({e})")))?;
1337    if !probe.status.success() {
1338        return Err(crate::CliError::Other(
1339            "snap: python3 has no Pillow (pip install pillow); map text written, recipe printed above".into(),
1340        ));
1341    }
1342    let dir = tempfile::TempDir::new()?;
1343    let map_path = dir.path().join("map.txt");
1344    let recipe_path = dir.path().join("snap.py");
1345    std::fs::write(&map_path, map)?;
1346    std::fs::write(&recipe_path, crate::viewer::snap_recipe())?;
1347    let out = std::process::Command::new("python3")
1348        .arg(&recipe_path)
1349        .arg(&map_path)
1350        .arg(dest)
1351        .output()
1352        .map_err(|e| crate::CliError::Other(format!("snap: cannot run python3 ({e}); recipe printed above")))?;
1353    if !out.status.success() {
1354        return Err(crate::CliError::Other(format!(
1355            "snap: Pillow render failed: {}",
1356            String::from_utf8_lossy(&out.stderr).trim()
1357        )));
1358    }
1359    Ok(())
1360}
1361
1362/// `scc view` — local web viewer (SPEC-SCC-VIEWER §2). Serves the loopback
1363/// viewer routes on an ephemeral port (or `--port`), then opens the
1364/// browser unless `--no-open`.
1365// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-view work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
1366pub fn cmd_view(root: &Path, port: Option<u16>, no_open: bool) -> crate::Result<()> {
1367    let store = open_store(root)?;
1368    if store.snapshot_status()?.is_none() {
1369        return Err(crate::CliError::Other("not indexed yet — run `scc index`".into()));
1370    }
1371    let addr = match port {
1372        Some(p) => format!("127.0.0.1:{p}"),
1373        None => "127.0.0.1:0".to_string(),
1374    };
1375    let server = tiny_http::Server::http(&addr)
1376        .map_err(|e| crate::CliError::Other(format!("cannot bind {addr}: {e}")))?;
1377    let bound = server.server_addr();
1378    let url = format!("http://{bound}/");
1379    println!("scc view: {url} (root {})", root.display());
1380    if !no_open {
1381        #[cfg(target_os = "macos")]
1382        let _ = std::process::Command::new("open").arg(&url).spawn();
1383        #[cfg(target_os = "linux")]
1384        let _ = std::process::Command::new("xdg-open").arg(&url).spawn();
1385    }
1386    for request in server.incoming_requests() {
1387        let url_path = request.url().to_string();
1388        let method = request.method().clone();
1389        if method == tiny_http::Method::Get && crate::viewer::is_viewer_path(&url_path) {
1390            let store = match open_store(root) {
1391                Ok(s) => s,
1392                Err(e) => {
1393                    let _ = request.respond(
1394                        tiny_http::Response::from_string(format!("store error: {e}"))
1395                            .with_status_code(500),
1396                    );
1397                    continue;
1398                }
1399            };
1400            let (status, body) = crate::viewer::serve_viewer(&store, &url_path);
1401            let response = tiny_http::Response::from_string(body)
1402                .with_status_code(status)
1403                .with_header(
1404                    tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
1405                        .unwrap(),
1406                );
1407            let _ = request.respond(response);
1408            continue;
1409        }
1410        // Viewer-only server: the daemon (`scc serve`) owns /v1/*.
1411        // Anything else is a 404 so typos fail loudly, not silently.
1412        let _ = request.respond(
1413            tiny_http::Response::from_string("no such viewer route (scc serve owns /v1/*)")
1414                .with_status_code(404),
1415        );
1416    }
1417    Ok(())
1418}
1419
1420/// `scc rpc --stdio`: structured JSON-RPC (SDK subprocess mode).
1421// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-rpc work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1422pub fn cmd_rpc(root: &Path, _stdio: bool) -> crate::Result<()> {
1423    scc_engine::rpc::serve_stdio(root).map_err(engine_err)
1424}
1425
1426/// `scc operations [--describe ID]`: introspection over the registry.
1427// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-operations work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1428pub fn cmd_operations(describe: Option<&str>) -> crate::Result<()> {
1429    if let Some(id) = describe {
1430        match scc_engine::ops::describe(id) {
1431            Some(d) => println!("{}", serde_json::to_string_pretty(&d)?),
1432            None => println!("unknown operation '{id}' (see `scc operations`)"),
1433        }
1434        return Ok(());
1435    }
1436    println!("Operation                 Mutation  Stream  Description");
1437    println!("----------------------------------------------------------------");
1438    for d in scc_engine::ops::OPERATIONS {
1439        println!("{:<26} {:<9} {:<7} {}", d.id, format!("{:?}", d.mutation), d.streaming, d.description);
1440    }
1441    Ok(())
1442}
1443
1444/// `scc plugin list`: enabled plugins with lock entries (engine owns the set).
1445// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-plugin-list work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1446pub fn cmd_plugin_list(root: &Path) -> crate::Result<()> {
1447    let out = scc_engine::invoke(root, "plugins.list", serde_json::json!({})).map_err(engine_err)?;
1448    println!("{}", serde_json::to_string_pretty(&out)?);
1449    Ok(())
1450}
1451
1452/// `scc plugin describe <id>`.
1453// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-plugin-describe work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1454pub fn cmd_plugin_describe(root: &Path, id: &str) -> crate::Result<()> {
1455    let out = scc_engine::invoke(root, "plugins.describe", serde_json::json!({"id": id})).map_err(engine_err)?;
1456    println!("{}", serde_json::to_string_pretty(&out)?);
1457    Ok(())
1458}
1459
1460/// `scc plugin doctor`.
1461// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-plugin-doctor work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1462pub fn cmd_plugin_doctor(root: &Path) -> crate::Result<()> {
1463    let out = scc_engine::invoke(root, "plugins.doctor", serde_json::json!({})).map_err(engine_err)?;
1464    println!("{}", serde_json::to_string_pretty(&out)?);
1465    Ok(())
1466}
1467
1468/// `scc plugin lock`: write .scc/plugins.lock from the live set.
1469// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-plugin-lock work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1470pub fn cmd_plugin_lock(root: &Path) -> crate::Result<()> {
1471    let out = scc_engine::invoke(root, "plugins.lock", serde_json::json!({})).map_err(engine_err)?;
1472    println!("{}", serde_json::to_string_pretty(&out)?);
1473    Ok(())
1474}
1475
1476/// `scc plugin check`: verify live plugins against .scc/plugins.lock.
1477// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-plugin-check work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1478pub fn cmd_plugin_check(root: &Path) -> crate::Result<()> {
1479    let out = scc_engine::invoke(root, "plugins.check", serde_json::json!({})).map_err(engine_err)?;
1480    println!("{}", serde_json::to_string_pretty(&out)?);
1481    if out.get("ok").and_then(|v| v.as_bool()) != Some(true) {
1482        return Err(crate::CliError::Other(format!(
1483            "plugin lock drift: {}",
1484            out.get("drift").map(|v| v.to_string()).unwrap_or_default()
1485        )));
1486    }
1487    Ok(())
1488}
1489
1490/// `scc plugin invoke <operation> [json-input]`.
1491// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-plugin-invoke work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1492pub fn cmd_plugin_invoke(root: &Path, operation: &str, input: &str) -> crate::Result<()> {
1493    let input: serde_json::Value = serde_json::from_str(input).unwrap_or(serde_json::json!({}));
1494    let out = scc_engine::invoke(root, operation, input).map_err(engine_err)?;
1495    println!("{}", serde_json::to_string_pretty(&out)?);
1496    Ok(())
1497}
1498
1499#[cfg(test)]
1500mod tests {
1501
1502    #[test]
1503    // trace:exempt reason=unit-test
1504    fn ensure_scc_ignored_keeps_intent_committable() {
1505        let dir = tempfile::TempDir::new().unwrap();
1506        let root = dir.path();
1507        crate::ensure_scc_ignored(root);
1508        let gi = std::fs::read_to_string(root.join(".gitignore")).unwrap();
1509        assert!(gi.contains(".scc/*"), "{gi}");
1510        assert!(gi.contains("!.scc/intent.yaml"), "{gi}");
1511        crate::ensure_scc_ignored(root);
1512        let gi2 = std::fs::read_to_string(root.join(".gitignore")).unwrap();
1513        assert_eq!(gi, gi2, "idempotent");
1514        std::fs::write(root.join(".gitignore"), "target/\n.scc/\n").unwrap();
1515        crate::ensure_scc_ignored(root);
1516        let gi3 = std::fs::read_to_string(root.join(".gitignore")).unwrap();
1517        assert!(gi3.contains("target/"), "{gi3}");
1518        assert!(!gi3.lines().any(|l| l.trim() == ".scc/"), "{gi3}");
1519        assert!(gi3.contains("!.scc/intent.yaml"), "{gi3}");
1520    }
1521
1522    #[test]
1523    // trace:exempt reason=unit-test
1524    fn detect_harnesses_finds_bins_dirs_and_project_dirs() {
1525        let home = tempfile::TempDir::new().unwrap();
1526        let bindir = home.path().join("bin");
1527        std::fs::create_dir_all(&bindir).unwrap();
1528        std::fs::write(bindir.join("codex"), "#!/bin/sh\n").unwrap();
1529        std::fs::create_dir_all(home.path().join(".config").join("opencode")).unwrap();
1530        let root = tempfile::TempDir::new().unwrap();
1531        std::fs::create_dir_all(root.path().join(".omp")).unwrap();
1532        let found = detect_harnesses(
1533            home.path(),
1534            &[bindir],
1535            root.path(),
1536        );
1537        assert!(found.contains(&SetupHarness::Codex), "codex via PATH: {found:?}");
1538        assert!(found.contains(&SetupHarness::Opencode), "opencode via config dir: {found:?}");
1539        assert!(found.contains(&SetupHarness::Omp), "omp via project dir: {found:?}");
1540        assert!(!found.contains(&SetupHarness::Claude), "no claude present: {found:?}");
1541        assert!(!found.contains(&SetupHarness::Pi), "no pi present: {found:?}");
1542    }
1543
1544    #[test]
1545    // trace:exempt reason=unit-test
1546    fn detect_harnesses_empty_when_nothing_present() {
1547        let home = tempfile::TempDir::new().unwrap();
1548        let root = tempfile::TempDir::new().unwrap();
1549        let found = detect_harnesses(home.path(), &[], root.path());
1550        assert!(found.is_empty(), "{found:?}");
1551    }
1552
1553    use super::*;
1554
1555
1556    #[test]
1557// trace:v1 id=impl.crates-scc-cli-src-commands.lessons-add-appends-jsonl-lines work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
1558    fn lessons_add_appends_jsonl_lines() {
1559        let dir = tempfile::TempDir::new().unwrap();
1560        let root = dir.path().join("repo");
1561        std::fs::create_dir_all(&root).unwrap();
1562        cmd_lessons_add(&root, "first lesson").unwrap();
1563        cmd_lessons_add(&root, "second lesson").unwrap();
1564        let path = root.join(".scc/lessons.jsonl");
1565        let text = std::fs::read_to_string(&path).unwrap();
1566        let lines: Vec<&str> = text.lines().collect();
1567        assert_eq!(lines.len(), 2, "a second add must append, not overwrite");
1568        let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
1569        assert_eq!(first["id"], "lesson-1");
1570        assert_eq!(first["text"], "first lesson");
1571        assert!(first["created_at"].is_string());
1572        let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
1573        assert_eq!(second["id"], "lesson-2");
1574        assert_eq!(second["text"], "second lesson");
1575        // the shape must be ingestable by the hindsight adapter
1576        let lesson: scc_indexer::adapters::hindsight::Lesson =
1577            serde_json::from_str(lines[0]).unwrap();
1578        assert_eq!(lesson.body(), "first lesson");
1579    }
1580
1581    #[test]
1582// trace:v1 id=impl.crates-scc-cli-src-commands.adapters-lists-configured-scope work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
1583    fn adapters_lists_configured_scope() {
1584        let dir = tempfile::TempDir::new().unwrap();
1585        let root = dir.path().join("repo");
1586        std::fs::create_dir_all(root.join(".scc")).unwrap();
1587        std::fs::write(
1588            root.join(".scc/config.yaml"),
1589            "schema: 1\nintegrations:\n  beads: true\n  context7_command: \"npx -y @upstash/context7-mcp\"\n",
1590        )
1591        .unwrap();
1592        let listing = configured_adapters(&root).unwrap();
1593        assert!(
1594            listing.contains(&("beads".to_string(), "filesystem")),
1595            "{listing:?}"
1596        );
1597        assert!(
1598            listing.contains(&("context7".to_string(), "network+subprocess(npx)")),
1599            "{listing:?}"
1600        );
1601        assert!(
1602            !listing.iter().any(|(n, _)| n == "hindsight"),
1603            "disabled integrations must not be listed: {listing:?}"
1604        );
1605        // scip/cbm are always-available on-demand importers
1606        assert!(listing.iter().any(|(n, _)| n == "scip"), "{listing:?}");
1607        assert!(listing.iter().any(|(n, _)| n == "cbm"), "{listing:?}");
1608        // One universe: every listed adapter resolves in the registry, and
1609        // the registry's evidence importers appear (tracelayer was missing).
1610        for (n, _) in &listing {
1611            assert!(
1612                scc_indexer::adapters::resolve_integration(n).is_some(),
1613                "listed adapter {n} must resolve in the registry: {listing:?}"
1614            );
1615        }
1616        assert!(listing.iter().any(|(n, _)| n == "tracelayer"), "{listing:?}");
1617        // narsil is a ccg alias, never a listed adapter.
1618        assert!(!listing.iter().any(|(n, _)| n == "narsil"), "{listing:?}");
1619        assert!(listing.iter().any(|(n, _)| n == "ccg"), "{listing:?}");
1620        // cmd_adapters renders the exact expected line format
1621        cmd_adapters(&root, false).unwrap();
1622        // doctor is offline, read-only, exit-0 by default on this fixture.
1623        assert!(cmd_doctor(&root, false, false, false, false).unwrap(), "doctor must pass clean");
1624        assert!(cmd_doctor(&root, true, false, false, false).unwrap(), "doctor --json must pass clean");
1625    }
1626}
1627