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    // Transient lock contention (watch/daemon writer holds the DB) must
8    // read as retryable, not as store corruption: the 30s busy_timeout
9    // covers short writers, but a long index can still collide. Never
10    // quarantine on BUSY — the DB is healthy, just busy.
11    // trace:inherit impl.crates-scc-cli-src-commands.cmd-index reason=lock-retry-message-inside-engine-err-funnel
12    let msg = e.to_string();
13    if msg.contains("database is locked") || msg.contains("database table is locked") {
14        return crate::CliError::Other(format!(
15            "{msg} (another scc process holds the index — retry, or stop `scc watch`/`scc serve` first)"
16        ));
17    }
18    crate::CliError::Other(msg)
19}
20use std::io::Write;
21use std::path::{Path, PathBuf};
22
23// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-init work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
24pub fn cmd_init(root: &Path) -> crate::Result<()> {
25    let dir = scc_dir(root);
26    std::fs::create_dir_all(&dir)?;
27    let cfg_path = config_path(root);
28    if !cfg_path.exists() {
29        std::fs::write(&cfg_path, scc_indexer::Config::default_yaml())?;
30        println!("created {}", cfg_path.display());
31    } else {
32        println!("config exists: {}", cfg_path.display());
33    }
34    // create the DB so the workspace is ready
35    let store = open_store(root)?;
36    crate::ensure_scc_ignored(root);
37    println!(
38        "initialized SCC workspace for repository '{}' at {}",
39        store.repo_name,
40        dir.display()
41    );
42    println!("next: scc index");
43    Ok(())
44}
45
46// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-index work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
47pub fn cmd_index(root: &Path, quiet: bool) -> crate::Result<()> {
48    let config = load_config(root)?;
49    let report = scc_engine::index::full(root, &config).map_err(engine_err)?;
50    if !quiet {
51        println!(
52            "indexed {} file(s) ({} changed, {} added, {} removed, {} failed) in {:.2}s",
53            report.indexed,
54            report.changed,
55            report.added,
56            report.removed,
57            report.failed,
58            report.duration_ms as f64 / 1000.0
59        );
60        println!("analysis_quality: {}", report.analysis_quality.compact_line());
61        let s = &report.scan_stats;
62        println!(
63            "files: discovered={} indexed={} ignored={} unsupported={} oversized={} unreadable={}",
64            s.discovered, s.indexed, s.ignored, s.unsupported, s.oversized, s.unreadable
65        );
66    }
67    Ok(())
68}
69
70// 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
71pub fn cmd_index_paths(root: &Path, paths: &[String], quiet: bool) -> crate::Result<()> {
72    let config = load_config(root)?;
73    let report = scc_engine::index::refresh_paths(root, &config, paths).map_err(engine_err)?;
74    if !quiet && report.indexed > 0 {
75        println!("refreshed {} file(s)", report.indexed);
76    }
77    Ok(())
78}
79
80// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-status work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
81pub fn cmd_status(root: &Path) -> crate::Result<()> {
82    let store = open_store(root)?;
83    let s = scc_engine::status::status(&store).map_err(engine_err)?;
84    println!("Repository: {} ({})", s.repository, s.repository_id);
85    if let Some(url) = &s.remote {
86        println!("Remote: {url}");
87    }
88    if s.indexed {
89        println!("Revision: {}", s.revision);
90        if let Some(b) = &s.branch {
91            println!("Branch: {b}");
92        }
93        println!("Indexed at: {}", s.indexed_at.as_deref().unwrap_or(""));
94        let mut keys: Vec<&String> = s.stats.keys().collect();
95        keys.sort();
96        for k in keys {
97            println!("{k}: {}", s.stats[k]);
98        }
99        if s.freshness == "CURRENT" {
100            println!("freshness: CURRENT — model matches working tree");
101        } else {
102            println!(
103                "freshness: STALE — {} file(s) changed since index (run `scc index`)",
104                s.stale_count
105            );
106            for f in &s.stale_files {
107                println!("  {f}");
108            }
109        }
110        if let Some(raw) = &s.analysis_quality {
111            match serde_json::from_str::<scc_core::AnalysisQuality>(raw) {
112                Ok(q) => println!("analysis_quality: {}", q.compact_line()),
113                Err(_) => println!("analysis_quality: {raw}"),
114            }
115        }
116        if let Some(v) = &s.scan_stats {
117            // Counts only, recorded at index time; live staleness is
118            // reported above from the working-tree scan.
119            let n = |k: &str| v.get(k).and_then(|x| x.as_u64()).unwrap_or(0);
120            println!(
121                "files: discovered={} indexed={} ignored={} unsupported={} oversized={} unreadable={}",
122                n("discovered"),
123                n("indexed"),
124                n("ignored"),
125                n("unsupported"),
126                n("oversized"),
127                n("unreadable")
128            );
129        }
130    } else {
131        println!("not indexed yet — run `scc index`");
132    }
133    Ok(())
134}
135
136// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-scan work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-NX53P4B7
137pub fn cmd_scan(root: &Path, path: Option<&str>, json: bool) -> crate::Result<()> {
138    let config = load_config(root)?;
139    let v = scc_engine::status::scan(root, &config, path).map_err(engine_err)?;
140    let indexed = v["indexed"].as_array().cloned().unwrap_or_default();
141    let skipped = v["skipped"].as_array().cloned().unwrap_or_default();
142    let strf = |o: &serde_json::Value, k: &str| o.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
143    if json {
144        println!("{v}");
145        return Ok(());
146    }
147    // Single path: one verdict line (exit 0 indexed, 1 skipped, 2 missing).
148    if let Some(q) = path {
149        let q = q.trim_start_matches("./");
150        if let Some(f) = indexed.iter().find(|f| f.get("path").and_then(|p| p.as_str()) == Some(q)) {
151            println!("{}: indexed ({} {})", strf(f, "path"), strf(f, "language"), strf(f, "kind"));
152            return Ok(());
153        }
154        if let Some(sk) = skipped.iter().find(|f| f.get("path").and_then(|p| p.as_str()) == Some(q)) {
155            match sk.get("rule").and_then(|r| r.as_str()) {
156                Some(rule) => println!("{q}: {} ({rule})", sk.get("reason").and_then(|r| r.as_str()).unwrap_or("")),
157                None => println!("{q}: {}", sk.get("reason").and_then(|r| r.as_str()).unwrap_or("")),
158            }
159            std::process::exit(1);
160        }
161        println!("{q}: not found (no such file in working tree)");
162        std::process::exit(2);
163    }
164    let st = &v["stats"];
165    let n = |k: &str| st.get(k).and_then(|x| x.as_u64()).unwrap_or(0);
166    println!(
167        "scan: discovered={} indexed={} ignored={} unsupported={} oversized={} unreadable={} (live walk, not index-time counts)",
168        n("discovered"), n("indexed"), n("ignored"), n("unsupported"), n("oversized"), n("unreadable")
169    );
170    if !skipped.is_empty() {
171        use std::collections::BTreeMap;
172        let mut by_rule: BTreeMap<String, usize> = BTreeMap::new();
173        for sk in &skipped {
174            let key = format!("{}: {}", sk.get("reason").and_then(|r| r.as_str()).unwrap_or("?"), sk.get("rule").and_then(|r| r.as_str()).unwrap_or("?"));
175            *by_rule.entry(key).or_default() += 1;
176        }
177        println!("\nskips by rule:");
178        for (rule, n) in &by_rule {
179            println!("  {n:>6}  {rule}");
180        }
181    }
182    // trace:inherit impl.crates-scc-cli-src-commands.cmd-scan reason=skipped-detail-cap-note
183    if v.get("skipped_detail_capped").and_then(|x| x.as_u64()).unwrap_or(0) > 0 {
184        println!(
185            "\n(detail capped: showing {} of {} skipped paths; counts above are exact — rerun with a path (`scc scan <path>`) or `--json` for one verdict)",
186            skipped.len(),
187            skipped.len() + v.get("skipped_detail_capped").and_then(|x| x.as_u64()).unwrap_or(0) as usize
188        );
189    }
190    // Indexed files: top-level dir histogram (where the index weight is).
191    {
192        use std::collections::BTreeMap;
193        let mut by_dir: BTreeMap<String, usize> = BTreeMap::new();
194        for f in &indexed {
195            let p = f.get("path").and_then(|x| x.as_str()).unwrap_or("");
196            let top = p.split('/').next().unwrap_or(".").to_string();
197            let key = if p.contains('/') { top } else { "(root)".to_string() };
198            *by_dir.entry(key).or_default() += 1;
199        }
200        println!("\nindexed by top dir:");
201        let mut rows: Vec<_> = by_dir.into_iter().collect();
202        rows.sort_by_key(|r| std::cmp::Reverse(r.1));
203        for (dir, n) in rows.iter().take(15) {
204            println!("  {n:>6}  {dir}/");
205        }
206    }
207    println!("\nrerun with a path (`scc scan <path>`) for one verdict, or `--json` for the full lists.");
208    Ok(())
209}
210
211/// `scc languages` — generated support matrix. Never hand-maintain a
212/// second list in CLI copy.
213// trace:v1 id=impl.scc.cli.languages work=WORK-ripwire-lessons-phase1 satisfies=REQ-language-support-matrix
214pub fn cmd_languages() -> crate::Result<()> {
215    print!("{}", scc_core::support_matrix_markdown());
216    Ok(())
217}
218
219// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-overview work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
220pub fn cmd_overview(root: &Path, json: bool) -> crate::Result<()> {
221    let store = open_store(root)?;
222    let config = load_config(root)?;
223    let stale = crate::stale_paths(&store)?;
224    let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
225    let pack = engine.context().overview().map_err(engine_err)?;
226    if json {
227        println!("{}", serde_json::to_string_pretty(&pack)?);
228    } else {
229        print!("{}", pack.content);
230    }
231    Ok(())
232}
233// trace:v1 id=impl.scc.cli work=WORK-SCC-001 satisfies=REQ-SCC-API
234
235/// `scc atlas [--budget N] [--json]` — the full System Atlas.
236// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-atlas work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
237pub fn cmd_atlas(
238    root: &Path,
239    budget: Option<usize>,
240    json: bool,
241    full: bool,
242    unbounded: bool,
243) -> crate::Result<()> {
244    let store = open_store(root)?;
245    let config = load_config(root)?;
246    let stale = crate::stale_paths(&store)?;
247    let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
248    let pack = engine.context().atlas(budget, full, unbounded).map_err(engine_err)?;
249    if json {
250        println!("{}", serde_json::to_string(&pack)?);
251    } else {
252        print!("{}", pack.content);
253    }
254    Ok(())
255}
256
257/// `scc context startup [--budget N]` — the Wave 14 startup artifact:
258/// the Atlas + Surface fusion, deterministic per epoch (prompt-cache
259/// stable). Records what the session just showed in the context ledger so
260/// task deltas suppress already-visible APIs.
261// trace:exempt reason=internal-detail
262// 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
263pub fn cmd_context_startup(root: &Path, budget_tokens: Option<usize>, full_size: bool) -> crate::Result<()> {
264    let store = open_store(root)?;
265    let config = load_config(root)?;
266    let stale = crate::stale_paths(&store)?;
267    let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
268    let (startup, text) = engine.context().startup(&scc_api::StartupRequest { budget: budget_tokens }).map_err(engine_err)?;
269    if full_size {
270        // Full-size accounting: what the pack cost, what didn't fit, and
271        // how to see each dropped piece. Agents use this to decide the
272        // next narrower command (surface/task/structural per section).
273        let delivered: usize = scc_core::estimate_tokens(&text);
274        println!("startup full-size accounting");
275        println!("  delivered_tokens: {delivered}");
276        println!("  surface_tokens: {}", startup.surface_render.token_count);
277        println!("  atlas_budget_used: {}", startup.atlas_budget_used);
278        println!("  coverage_notes: {}", startup.coverage.len());
279        if startup.omissions.iter().any(|o| o != "none") {
280            println!("dropped/truncated sections:");
281            for o in &startup.omissions {
282                if o == "none" {
283                    continue;
284                }
285                println!("  - {o}");
286            }
287            println!("recover with: scc surface --explain | scc context task <goal> | scc context structural --files <paths>");
288        } else {
289            println!("dropped/truncated sections: none (pack is complete)");
290        }
291        return Ok(());
292    }
293    print!("{text}");
294    Ok(())
295}
296
297/// `scc surface [--task "<goal>"] [--budget N] [--explain]` — the System
298/// Surface Map: the actual callable API layer, global or task-personalized.
299/// BOTH modes run the ONE authoritative service — `build_surface` (Global
300/// or Task) — so the CLI, MCP, hermes, and the SDKs render the same
301/// artifact for the same request (no parallel pipelines). The rendered
302/// entries are recorded in the context ledger.
303// trace:exempt reason=internal-detail
304// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-surface work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
305pub fn cmd_surface(
306    root: &Path,
307    task: Option<&str>,
308    budget: Option<usize>,
309    explain: bool,
310) -> crate::Result<()> {
311    let store = open_store(root)?;
312    let config = load_config(root)?;
313    let stale = crate::stale_paths(&store)?;
314    let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
315    let tokens = budget.unwrap_or(scc_core::ContextBudget::default().surface);
316    // Semantic scorer (SCC-071): wired in when the embed_cli rankers are
317    // available (inference.enabled + remote-model policy) and the surface
318    // is task-personalized (a scorer rates entities against a goal; the
319    // global surface has no goal). Disabled embeddings → None, and the
320    // pipeline explicitly redistributes the 10% semantic share.
321    let scorer = match task {
322        Some(goal) => {
323            let (scorer, _reranker) = scc_engine::inference::rankers(&store, &config, goal);
324            scorer
325        }
326        None => None,
327    };
328    let semantic: Option<&dyn scc_context::rank::SemanticScorer> =
329        scorer.as_ref().map(|s| s as &dyn scc_context::rank::SemanticScorer);
330    let req = scc_api::SurfaceRequest { task: task.map(|s| s.to_string()), budget: Some(tokens), explain, stages: None };
331    let (_result, text) = engine.context().surface(&req, semantic).map_err(engine_err)?;
332    print!("{text}");
333    Ok(())
334}
335
336/// `scc important [--limit N] [--component C] [--task G] [--json]` —
337/// the fast "where do I pay attention first" answer (audit item 3). Global
338/// mode ranks by architectural centrality (global PPR + badges); --task
339/// re-ranks by task PPR and renders TASK-CRITICAL SYMBOLS. --component
340/// filters to one component substring. No new MCP tool: the section also
341/// ships inside startup/task context.
342// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-important work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
343pub fn cmd_important(
344    root: &Path,
345    limit: usize,
346    component: Option<&str>,
347    task: Option<&str>,
348    json: bool,
349) -> crate::Result<()> {
350    let store = open_store(root)?;
351    let config = load_config(root)?;
352    let stale = crate::stale_paths(&store)?;
353    let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
354    let (entries, tasked) = engine.context().important(limit, component, task).map_err(engine_err)?;
355    if json {
356        println!("{}", serde_json::to_string_pretty(&entries)?);
357        return Ok(());
358    }
359    print!("{}", scc_context::surface::render_important(&entries, tasked));
360    Ok(())
361}
362
363/// `scc context structural --files <paths...> | --task "<goal>" [--budget N]` —
364/// the Structural Source product surface (fixwave Item 7): the per-file
365/// signature/structural representation of the requested files, or of the
366/// files matched to a task goal. Returns the rendered text so every
367/// transport (CLI, MCP) shares one implementation.
368// trace:exempt reason=internal-detail
369// 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
370pub fn cmd_context_structural(
371    root: &Path,
372    files: &[String],
373    task: Option<&str>,
374    budget: Option<usize>,
375) -> crate::Result<String> {
376    // Registry derivation: parse + dispatch only; the engine owns the build.
377    let out = scc_engine::invoke(
378        root,
379        "context.structural",
380        serde_json::json!({"files": files, "task": task, "budget": budget}),
381    )
382    .map_err(engine_err)?;
383    Ok(out.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string())
384}
385
386/// THE one complete task artifact (transport parity): the enriched task
387/// pack AND its surface delta derived together, from ONE builder.
388#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
389// 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
390pub struct TaskContextArtifact {
391    pub pack: scc_context::ContextPack,
392    /// The task-personalized Surface delta (only NEW relevant APIs vs the
393    /// ledger). Empty string when the delta budget is 0.
394    pub delta: String,
395    /// Entry ids the delta rendered (ledger recording). ALWAYS serialized
396    /// (empty array, not omitted) — the public JSON contract the SDKs type.
397    #[serde(default)]
398    pub delta_ids: Vec<String>,
399    /// Actual token count of the complete rendered artifact:
400    /// `estimate_tokens(pack.content) + estimate_tokens(delta)` computed
401    /// AFTER all enrichment (Beads/Hindsight), never from stale component
402    /// estimates — the accounting the SDKs and benchmarking rely on.
403    #[serde(default)]
404    pub token_count: usize,
405}
406
407/// Build the complete task artifact: scorer + beads + hindsight enrichment
408/// for the pack, then the SAME-scorer task delta against the ledger.
409/// `hook` keeps the whole focus within the §37 1500-token cap (the delta
410/// gets what the pack leaves); an explicit budget caps the total; the
411/// default gives the delta its own `task_delta` slice.
412// 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
413pub fn build_task_context(
414    root: &Path,
415    goal: &str,
416    files: &[String],
417    symbols: &[String],
418    budget: Option<usize>,
419    hook: bool,
420) -> crate::Result<TaskContextArtifact> {
421    build_task_context_engine(root, goal, files, symbols, budget, hook).map_err(engine_err).map(from_engine_artifact)
422}
423
424// trace:exempt reason=internal-detail
425fn from_engine_artifact(a: scc_engine::TaskContextArtifact) -> TaskContextArtifact {
426    TaskContextArtifact { pack: a.pack, delta: a.delta, delta_ids: a.delta_ids, token_count: a.token_count }
427}
428
429// trace:exempt reason=internal-detail
430fn build_task_context_engine(
431    root: &Path,
432    goal: &str,
433    files: &[String],
434    symbols: &[String],
435    budget: Option<usize>,
436    hook: bool,
437) -> scc_engine::Result<scc_engine::TaskContextArtifact> {
438    // Registry derivation: the engine owns the task build; this crate only
439    // parses args and renders. One path for CLI/HTTP/MCP/SDKs (spec 2).
440    let out = scc_engine::invoke(
441        root,
442        "context.task",
443        serde_json::json!({"goal": goal, "files": files, "symbols": symbols, "budget": budget, "hook": hook}),
444    )?;
445    serde_json::from_value(out).map_err(|e| scc_engine::EngineError::Other(e.to_string()))
446}
447
448/// pack with scorer + beads + hindsight and its post-enrichment token
449/// count. NO Surface delta, NO ContextLedger mutation, NO visibility side
450/// effects. Pack-only callers (`context compress`, [`build_task_pack`])
451/// MUST use this — NEVER [`build_task_context`], which also builds a delta
452/// and records its rendered ids in the ledger: throwing the delta away
453/// would mark Surface APIs "already visible" the agent never saw, breaking
454/// the ledger invariant (ledger = content actually shown to the agent).
455// 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
456pub fn build_enriched_task_pack(
457    root: &Path,
458    goal: &str,
459    files: &[String],
460    symbols: &[String],
461    budget: Option<usize>,
462    hook: bool,
463) -> crate::Result<scc_context::ContextPack> {
464    // Registry derivation: the engine owns the pack build (spec 2).
465    let out = scc_engine::invoke(
466        root,
467        "context.task_pack",
468        serde_json::json!({"goal": goal, "files": files, "symbols": symbols, "budget": budget, "hook": hook}),
469    )
470    .map_err(engine_err)?;
471    // Engine returns the pack directly (ContextPack), not wrapped.
472    serde_json::from_value(out).map_err(|e: serde_json::Error| crate::CliError::Other(e.to_string()))
473}
474
475/// `scc context task <goal> [--budget N] [--json] [--hook]` — the complete
476/// task focus. Text and JSON are TWO VIEWS OF ONE ARTIFACT built by
477/// [`build_task_context`]: JSON carries `{pack, delta, delta_ids}`, text
478/// prints pack content followed by the delta — identical derivation, no
479/// transport downgrades quality.
480// 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
481pub fn cmd_context_task(
482    root: &Path,
483    goal: &str,
484    files: &[String],
485    symbols: &[String],
486    budget: Option<usize>,
487    json: bool,
488    hook: bool,
489) -> crate::Result<()> {
490    if hook {
491        // Wave 2 (§37): UserPromptSubmit injects a task focus only when
492        // context.inject_task_focus is enabled; otherwise silent no-op.
493        let config = load_config(root)?;
494        if !config.context.inject_task_focus {
495            return Ok(());
496        }
497    }
498    let artifact = build_task_context(root, goal, files, symbols, budget, hook)?;
499    if json {
500        println!("{}", serde_json::to_string_pretty(&artifact)?);
501    } else {
502        print!("{}", artifact.pack.content);
503        print!("\n{}", artifact.delta);
504    }
505    Ok(())
506}
507
508
509/// half built by the pure [`build_enriched_task_pack`] — NO delta, NO
510/// ledger side effects. New transports should call
511/// [`build_task_context`] directly so the delta ships with the pack.
512// trace:exempt reason=internal-detail
513pub fn build_task_pack(
514    root: &Path,
515    goal: &str,
516    files: &[String],
517    symbols: &[String],
518    budget: Option<usize>,
519) -> crate::Result<scc_context::ContextPack> {
520    build_enriched_task_pack(root, goal, files, symbols, budget, false)
521}
522/// The complete task artifact as JSON — the same derivation as CLI text
523/// (`{pack, delta, delta_ids}`); serialization is the only difference.
524// 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
525pub fn cmd_context_task_json(
526    root: &Path,
527    goal: &str,
528    files: &[String],
529    symbols: &[String],
530    budget: Option<usize>,
531) -> crate::Result<String> {
532    Ok(serde_json::to_string_pretty(&build_task_context(
533        root, goal, files, symbols, budget, false,
534    )?)?)
535}
536
537/// `scc context docs <dependency>` — external library docs via Context7
538/// (labeled external; never mixed with repository facts).
539// 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
540pub fn cmd_context_docs(root: &Path, dependency: &str) -> crate::Result<()> {
541    print!("{}", scc_engine::invoke(root, "context.external_docs", serde_json::json!({"dependency": dependency})).map_err(engine_err)?);
542    Ok(())
543}
544
545/// Subagent context policy (SCC-107, docs/API_AND_INTEGRATIONS.md §5):
546/// a narrower, tighter-budget task pack with explicit scope boundaries so
547/// delegated agents start from the same system model without re-deriving it.
548// 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
549pub fn cmd_context_subagent(
550    root: &Path,
551    goal: &str,
552    files: &[String],
553    symbols: &[String],
554    budget: Option<usize>,
555    json: bool,
556) -> crate::Result<()> {
557    let store = open_store(root)?;
558    let config = load_config(root)?;
559    let stale = crate::stale_paths(&store)?;
560    let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
561    let pack = engine.context().subagent(goal, files, symbols, budget).map_err(engine_err)?;
562    if json {
563        println!("{}", serde_json::to_string_pretty(&pack)?);
564    } else {
565        print!("{}", pack.content);
566    }
567    Ok(())
568}
569
570// 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
571pub fn cmd_context_component(root: &Path, id: &str, json: bool, unbounded: bool) -> crate::Result<()> {
572    let store = open_store(root)?;
573    let config = load_config(root)?;
574    let stale = crate::stale_paths(&store)?;
575    let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
576    let pack = engine.context().component(&scc_api::DetailRequest { id: id.into(), unbounded }).map_err(engine_err)?;
577    if json {
578        println!("{}", serde_json::to_string_pretty(&pack)?);
579    } else {
580        print!("{}", pack.content);
581    }
582    Ok(())
583}
584
585// 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
586pub fn cmd_context_flow(root: &Path, id: &str, json: bool, unbounded: bool) -> crate::Result<()> {
587    let store = open_store(root)?;
588    let config = load_config(root)?;
589    let stale = crate::stale_paths(&store)?;
590    let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
591    let pack = engine.context().flow(&scc_api::DetailRequest { id: id.into(), unbounded }).map_err(engine_err)?;
592    if json {
593        println!("{}", serde_json::to_string_pretty(&pack)?);
594    } else {
595        print!("{}", pack.content);
596    }
597    Ok(())
598}
599
600// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-impact work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
601pub fn cmd_impact(
602    root: &Path,
603    files: &[String],
604    symbols: &[String],
605    diff: Option<&str>,
606    json: bool,
607    unbounded: bool,
608) -> crate::Result<()> {
609    let store = open_store(root)?;
610    let config = load_config(root)?;
611    let stale = crate::stale_paths(&store)?;
612    let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
613    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)?;
614    if json {
615        println!("{}", serde_json::to_string_pretty(&pack)?);
616    } else {
617        print!("{}", pack.content);
618    }
619    Ok(())
620}
621
622// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-verify work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
623pub fn cmd_verify(
624    root: &Path,
625    warnings_only: bool,
626    json: bool,
627    unbounded: bool,
628) -> crate::Result<()> {
629    let store = open_store(root)?;
630    let config = load_config(root)?;
631    let stale = crate::stale_paths(&store)?;
632    let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
633    let pack = engine.context().verify(unbounded).map_err(engine_err)?;
634    if warnings_only {
635        for w in &pack.warnings {
636            println!("⚠ {w}");
637        }
638        return Ok(());
639    }
640    if json {
641        println!("{}", serde_json::to_string_pretty(&pack)?);
642        return Ok(());
643    }
644    // trace:inherit impl.crates-scc-cli-src-commands.cmd-verify reason=trailing-newline-for-shell-glue
645    print!("{}", pack.content);
646    if !pack.content.ends_with('\n') {
647        println!();
648    }
649    Ok(())
650}
651
652// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-drift work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
653pub fn cmd_drift(root: &Path, json: bool) -> crate::Result<()> {
654    let store = open_store(root)?;
655    let findings = scc_engine::misc::drift(&store).map_err(engine_err)?;
656    if json {
657        println!("{}", serde_json::to_string_pretty(&findings)?);
658    } else {
659        if findings.is_empty() {
660            println!("no drift findings");
661        }
662        for f in &findings {
663            println!("[{0}] {1} (#{2}, {3}): {4}", f.severity, f.kind, f.id, f.created_at, f.message);
664        }
665    }
666    Ok(())
667}
668
669// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-system work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
670pub fn cmd_system(_root: &Path, members: &[std::path::PathBuf], json: bool) -> crate::Result<()> {
671    let (members, all) = scc_engine::systems::stitch(members).map_err(engine_err)?;
672    if json {
673        println!("{}", serde_json::to_string_pretty(&all)?);
674        return Ok(());
675    }
676    println!(
677        "system: {} members, {} stitches",
678        members.len(),
679        all.len()
680    );
681    for m in &members {
682        println!("member {} ({})", m.repo_id, m.root.display());
683    }
684    for s in &all {
685        println!(
686            "[{:?}/{:?}] {} ({} ends)",
687            s.kind,
688            s.match_kind,
689            s.key,
690            s.ends.len()
691        );
692        for e in &s.ends {
693            match e.role {
694                Some(role) => println!("  {} {} ({role:?})", e.repo_id, e.entity_id),
695                None => println!("  {} {}", e.repo_id, e.entity_id),
696            }
697        }
698    }
699    Ok(())
700}
701
702// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-history work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
703pub fn cmd_history(root: &Path, json: bool) -> crate::Result<()> {
704    let store = open_store(root)?;
705    let revs = scc_engine::history::revisions(&store).map_err(engine_err)?;
706    if json {
707        println!("{}", serde_json::to_string_pretty(&revs)?);
708        return Ok(());
709    }
710    if revs.is_empty() {
711        println!("no graph revisions recorded");
712        return Ok(());
713    }
714    for r in &revs {
715        println!(
716            "rev {} (base {}): {} entities, {} rels, {} files | src={} ext={} | {}",
717            r.rev,
718            r.base_rev,
719            r.entity_count,
720            r.rel_count,
721            r.file_count,
722            &r.source_hash[..r.source_hash.len().min(12)],
723            r.extractor_version,
724            r.created_at,
725        );
726    }
727    Ok(())
728}
729
730// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-diff work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
731pub fn cmd_diff(root: &Path, from: i64, to: i64, json: bool) -> crate::Result<()> {
732    let store = open_store(root)?;
733    let d = scc_engine::history::diff(&store, from, to).map_err(engine_err)?;
734    if json {
735        println!("{}", serde_json::to_string_pretty(&d)?);
736        return Ok(());
737    }
738    // Bounded human rendering: counts plus the first 20 ids per class.
739    println!("diff rev {from}..{to}:");
740    for (label, ids) in [
741        ("added entities", &d.added_entities),
742        ("removed entities", &d.removed_entities),
743        ("modified entities", &d.modified_entities),
744        ("added relationships", &d.added_relationships),
745        ("removed relationships", &d.removed_relationships),
746        ("modified relationships", &d.modified_relationships),
747    ] {
748        println!("  {label}: {}", ids.len());
749        for id in ids.iter().take(20) {
750            println!("    {id}");
751        }
752        if ids.len() > 20 {
753            println!("    … ({} more)", ids.len() - 20);
754        }
755    }
756    if !d.modified_kinds.is_empty() {
757        let kinds: Vec<String> = d
758            .modified_kinds
759            .iter()
760            .map(|(k, v)| format!("{k}:{v}"))
761            .collect();
762        println!("  modified kinds: {}", kinds.join(", "));
763    }
764    Ok(())
765}
766
767// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-snapshot-save work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
768pub fn cmd_snapshot_save(
769    root: &Path,
770    task: &str,
771    budget: Option<usize>,
772    json: bool,
773) -> crate::Result<()> {
774    let snap = scc_engine::misc::snapshot_save(root, task, budget).map_err(engine_err)?;
775    if json {
776        println!("{}", serde_json::to_string_pretty(&snap)?);
777    } else {
778        println!("snapshot {} ({} visible ids)", snap.id, snap.entity_ids.len());
779    }
780    Ok(())
781}
782
783// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-snapshot-show work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
784pub fn cmd_snapshot_show(root: &Path, id: &str) -> crate::Result<()> {
785    let store = open_store(root)?;
786    match scc_engine::snapshots::get(&store, id).map_err(engine_err)? {
787        Some(s) => print!("{}", s.artifact),
788        None => println!("no snapshot {id}"),
789    }
790    Ok(())
791}
792
793// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-snapshot-diff work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
794pub fn cmd_snapshot_diff(root: &Path, id: &str, json: bool) -> crate::Result<()> {
795    let store = open_store(root)?;
796    match scc_engine::snapshots::diff(&store, id).map_err(engine_err)? {
797        Some(d) => {
798            if json {
799                println!("{}", serde_json::to_string_pretty(&d)?);
800            } else {
801                println!(
802                    "snapshot rev {} vs current rev {}: {} still valid, {} invalidated, {} modified{}",
803                    d.snapshot_revision,
804                    d.current_revision,
805                    d.still_valid.len(),
806                    d.invalidated.len(),
807                    d.modified_entities.len(),
808                    if d.artifact_changed { " (artifact would re-render differently)" } else { "" },
809                );
810                for (label, ids) in [
811                    ("invalidated", &d.invalidated),
812                    ("modified entities", &d.modified_entities),
813                    ("changed relationships", &d.changed_relationships),
814                    ("changed contracts", &d.changed_contracts),
815                    ("changed state", &d.changed_state),
816                    ("changed flows", &d.changed_flows),
817                ] {
818                    if ids.is_empty() {
819                        continue;
820                    }
821                    println!("  {label} ({}):", ids.len());
822                    for f in ids.iter().take(20) {
823                        println!("    - {f}");
824                    }
825                    if ids.len() > 20 {
826                        println!("    … ({} more)", ids.len() - 20);
827                    }
828                }
829            }
830        }
831        None => println!("no snapshot {id}"),
832    }
833    Ok(())
834}
835
836// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-export work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
837pub fn cmd_export(root: &Path, format: &str) -> crate::Result<()> {
838    let store = open_store(root)?;
839    match format {
840        "system-ir.json" => println!("{}", serde_json::to_string_pretty(&scc_engine::exports::system_ir(&store).map_err(engine_err)?)?),
841        "system-ir.jsonl" => {
842            let ir = scc_engine::exports::system_ir(&store).map_err(engine_err)?;
843            for line in scc_engine::exports::jsonl(&ir).map_err(engine_err)? {
844                println!("{line}");
845            }
846        }
847        "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)?)?),
848        "flow-graphs.json" => {
849            println!(
850                "{}",
851                serde_json::to_string_pretty(&store.flow_graphs()?)?
852            )
853        }
854        "capsule.md" => print!("{}", scc_engine::exports::capsule(root).map_err(engine_err)?),
855        other => {
856            return Err(crate::CliError::Other(format!(
857                "unknown export format '{other}' (use system-ir.json, system-ir.jsonl, ccg, or capsule.md)"
858            )))
859        }
860    }
861    Ok(())
862}
863
864// trace:v1 id=impl.query-where-first-header work=WORK-SI-Z1KJWXDQ satisfies=REQ-SI-503JSBGP
865// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-query work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
866// trace:v1 id=impl.cli.query.fallback work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
867pub fn cmd_query(root: &Path, query: &str, limit: usize) -> crate::Result<()> {
868    let store = open_store(root)?;
869    let hit = scc_engine::graph::query(&store, &scc_api::QueryRequest { query: query.into(), limit }).map_err(engine_err)?;
870    // Issue #13: defining file:line is the answer to "where is X defined"
871    // and must lead the output — an agent skimming the top previously saw
872    // ~20 lines of bare entity names with no location. The symbol block
873    // (which carries file:line) now renders first as `where:`, then the
874    // full entity/symbol detail below for downstream consumers.
875    if !hit.symbols.is_empty() {
876        println!("— where —");
877        for (name, _sig, _kind, file, line) in &hit.symbols {
878            if *line > 0 {
879                println!("where: {name} {file}:{line}");
880            } else {
881                println!("where: {name} {file}");
882            }
883        }
884    }
885    println!("— entities —");
886    for e in &hit.entities {
887        println!("{} [{}]", e.name, e.kind);
888    }
889    println!("— symbols —");
890    for (name, sig, kind, file, line) in &hit.symbols {
891        if *line > 0 {
892            println!("{name} ({kind}) {file}:{line} {sig}");
893        } else {
894            println!("{name} ({kind}) {file} {sig}");
895        }
896    }
897    Ok(())
898}
899
900/// `scc traverse`: parse step specs, build the request, render engine results.
901// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-traverse work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
902pub fn cmd_traverse(
903    root: &Path,
904    kind: Option<&str>,
905    name: Option<&str>,
906    from: &[String],
907    steps: &[String],
908    limit: usize,
909) -> crate::Result<()> {
910    let mut parsed = Vec::new();
911    for spec in steps {
912        let mut parts = spec.splitn(3, ':');
913        let dir = parts.next().unwrap_or("").to_string();
914        if !["out", "in", "both"].contains(&dir.as_str()) {
915            return Err(crate::CliError::Other(format!(
916                "bad --step '{spec}' (dir must be out|in|both, e.g. out:calls:symbol)"
917            )));
918        }
919        parsed.push(scc_api::TraverseStep {
920            dir,
921            predicate: parts.next().filter(|s| !s.is_empty()).map(str::to_string),
922            where_kind: parts.next().filter(|s| !s.is_empty()).map(str::to_string),
923            limit: 0,
924        });
925    }
926    let store = open_store(root)?;
927    let config = load_config(root)?;
928    let stale = crate::stale_paths(&store)?;
929    let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
930    let cc = engine.ctx();
931    let (entities, rels) = scc_engine::graph::traverse(
932        &cc,
933        &scc_api::TraverseRequest {
934            kind: kind.map(str::to_string),
935            name: name.map(str::to_string),
936            from_ids: from.to_vec(),
937            steps: parsed,
938            limit,
939            trusted_only: true,
940        },
941    )
942    .map_err(engine_err)?;
943    println!("— entities ({}) —", entities.len());
944    for e in &entities {
945        println!("{} [{}] {}", e.name, e.kind, e.id);
946    }
947    println!("— relationships ({}) —", rels.len());
948    for r in &rels {
949        println!("{} -{}-> {}", r.subject, r.predicate, r.object);
950    }
951    Ok(())
952}
953
954// 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
955pub fn cmd_list_components(root: &Path) -> crate::Result<()> {
956    let store = open_store(root)?;
957    for c in scc_engine::graph::components(&store).map_err(engine_err)? {
958        println!("{}", c.name);
959    }
960    Ok(())
961}
962
963// 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
964pub fn cmd_list_flows(root: &Path) -> crate::Result<()> {
965    let store = open_store(root)?;
966    for f in scc_engine::graph::flows(&store).map_err(engine_err)? {
967        println!("{} [{}] {}", f.name, crate::flow_kind_str(&f.kind), f.trigger.unwrap_or_default());
968    }
969    Ok(())
970}
971
972/// `scc cochange`: print the git co-change pairs (files changed together
973/// across commits) and, when an indexed store exists, enrich its components
974/// with the signal.
975// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-cochange work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
976pub fn cmd_cochange(root: &Path, min_commits: u32) -> crate::Result<()> {
977    let (pairs, n) = scc_engine::misc::cochange(root, min_commits).map_err(engine_err)?;
978    if pairs.is_empty() {
979        println!("no co-change pairs with >= {min_commits} shared commits");
980    } else {
981        println!("co-change pairs (>= {min_commits} shared commits):");
982        for p in &pairs {
983            println!("  {} <-> {} ×{}", p.a, p.b, p.commits);
984        }
985    }
986    if n > 0 {
987        println!("enriched {n} components with co-change signal");
988    }
989    Ok(())
990}
991
992/// scc verify --graph-invariants: structural checks for CI.
993// 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
994pub fn cmd_check_invariants(root: &Path) -> crate::Result<bool> {
995    let store = open_store(root)?;
996    let violations = scc_engine::misc::check_invariants(&store).map_err(engine_err)?;
997    for v in &violations {
998        println!("{}", v.message);
999    }
1000    Ok(violations.is_empty())
1001}
1002
1003/// `scc ci check` (docs/DEPLOYMENT_AND_INFRA.md §3, EPIC-180 CI policies):
1004/// graph invariants + drift severity policy. Exits nonzero on violation.
1005// 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
1006pub fn cmd_ci_check(root: &Path, max_severity: &str) -> crate::Result<bool> {
1007    let store = open_store(root)?;
1008    let violations = scc_engine::misc::check_invariants(&store).map_err(engine_err)?;
1009    for v in &violations {
1010        println!("{}", v.message);
1011    }
1012    let (ok, lines) = scc_engine::misc::ci_check(&store, &violations, max_severity).map_err(engine_err)?;
1013    for l in &lines {
1014        // invariant lines already printed above; print only ci lines + summary
1015        if l.starts_with("[ci:") || *l == "ci check passed" {
1016            println!("{l}");
1017        }
1018    }
1019    Ok(ok)
1020}
1021
1022// 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
1023pub fn cmd_checkpoint_save(root: &Path, json: bool) -> crate::Result<()> {
1024    let data = scc_engine::checkpoint::capture(root).map_err(engine_err)?;
1025    if json {
1026        println!("{}", serde_json::to_string(&data)?);
1027    } else {
1028        println!("checkpoint saved to {}", crate::checkpoint_path(root).display());
1029    }
1030    Ok(())
1031}
1032
1033// 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
1034pub fn cmd_checkpoint_load(root: &Path, inject: bool) -> crate::Result<()> {
1035    if let Some(content) = scc_engine::checkpoint::load(root).map_err(engine_err)? {
1036        print!("{content}");
1037    } else if !inject {
1038        println!("no checkpoint found");
1039    }
1040    Ok(())
1041}
1042
1043// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-watch work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
1044pub fn cmd_watch(root: &Path) -> crate::Result<()> {
1045    crate::httpd::watch_loop(root)
1046}
1047
1048// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-serve work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
1049pub fn cmd_serve(root: &Path) -> crate::Result<()> {
1050    crate::httpd::serve(root)
1051}
1052
1053// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-mcp work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
1054pub fn cmd_mcp(root: &Path) -> crate::Result<()> {
1055    crate::mcp::serve_stdio(root)
1056}
1057
1058// 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
1059pub fn cmd_setup_claude(root: &Path) -> crate::Result<()> {
1060    crate::plugin::install(root)
1061}
1062
1063// Setup targets for harness auto-detection. Hermes is deliberately absent:
1064// it installs into a home directory outside the repo (different trust
1065// domain), so it stays an explicit opt-in.
1066#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1067// trace:exempt reason=internal-detail
1068pub enum SetupHarness {
1069    Claude,
1070    Codex,
1071    Opencode,
1072    Omp,
1073    Pi,
1074}
1075
1076// Pure detection: a harness counts as present when its binary is on PATH
1077// or its home/config dir exists (or, for project-local OMP/Pi, the repo
1078// already carries the harness dir). `path_dirs` is PATH split already so
1079// tests never touch the process environment.
1080// trace:exempt reason=internal-detail
1081pub fn detect_harnesses(home: &Path, path_dirs: &[PathBuf], root: &Path) -> Vec<SetupHarness> {
1082    let on_path = |bin: &str| {
1083        path_dirs
1084            .iter()
1085            .any(|d| d.join(bin).is_file() || d.join(format!("{bin}.exe")).is_file())
1086    };
1087    let mut out = Vec::new();
1088    if on_path("claude") || home.join(".claude").is_dir() {
1089        out.push(SetupHarness::Claude);
1090    }
1091    if on_path("codex") || home.join(".codex").is_dir() {
1092        out.push(SetupHarness::Codex);
1093    }
1094    if on_path("opencode") || home.join(".config").join("opencode").is_dir() {
1095        out.push(SetupHarness::Opencode);
1096    }
1097    if on_path("omp") || root.join(".omp").is_dir() {
1098        out.push(SetupHarness::Omp);
1099    }
1100    if on_path("pi") || root.join(".pi").is_dir() || home.join(".pi").is_dir() {
1101        out.push(SetupHarness::Pi);
1102    }
1103    out
1104}
1105
1106// trace:exempt reason=internal-detail
1107fn install_harness(root: &Path, h: SetupHarness) -> crate::Result<()> {
1108    match h {
1109        SetupHarness::Claude => cmd_setup_claude(root),
1110        SetupHarness::Codex => crate::compress::cmd_setup_codex(root),
1111        SetupHarness::Opencode => crate::compress::cmd_setup_opencode(root),
1112        SetupHarness::Omp => crate::plugin_omp::cmd_setup_omp(root),
1113        SetupHarness::Pi => crate::plugin_omp::cmd_setup_pi(root),
1114    }
1115}
1116
1117/// `scc setup` (no subcommand): install for every detected harness and
1118/// print one summary, including the manual steps setup cannot perform
1119/// (Codex hooks live in user scope). `scc setup all` skips detection and
1120/// installs for every harness unconditionally.
1121// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-setup-detected work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1122pub fn cmd_setup_detected(root: &Path, all: bool) -> crate::Result<()> {
1123    let targets: Vec<SetupHarness> = if all {
1124        vec![
1125            SetupHarness::Claude,
1126            SetupHarness::Codex,
1127            SetupHarness::Opencode,
1128            SetupHarness::Omp,
1129            SetupHarness::Pi,
1130        ]
1131    } else {
1132        let home = std::env::var_os("HOME")
1133            .map(PathBuf::from)
1134            .unwrap_or_else(|| PathBuf::from("/"));
1135        let path_dirs: Vec<PathBuf> = std::env::var_os("PATH")
1136            .map(|p| std::env::split_paths(&p).collect())
1137            .unwrap_or_default();
1138        detect_harnesses(&home, &path_dirs, root)
1139    };
1140    if targets.is_empty() {
1141        println!("No supported harness detected (looked for claude/codex/opencode/omp/pi");
1142        println!("binaries, ~/.claude, ~/.codex, ~/.config/opencode, ~/.pi, and .omp//.pi/ dirs).");
1143        println!("Run `scc setup all` to install for every harness, or `scc setup <harness>`.");
1144        return Ok(());
1145    }
1146    for h in &targets {
1147        println!("=== {:?} ===", h);
1148        install_harness(root, *h)?;
1149        println!();
1150    }
1151    println!("Installed for: {}", targets.iter().map(|h| format!("{h:?}")).collect::<Vec<_>>().join(", "));
1152    if targets.contains(&SetupHarness::Codex) {
1153        println!("Remaining manual step: add the printed entry to ~/.codex/hooks.json (user scope).");
1154    }
1155    Ok(())
1156}
1157
1158// 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
1159pub fn cmd_ingest_runtime(root: &Path, body: &str) -> crate::Result<()> {
1160    scc_engine::state::ingest_runtime(root, body).map_err(engine_err)?;
1161    println!("accepted");
1162    Ok(())
1163}
1164
1165/// Declared capability scope per adapter (docs/SECURITY.md §6: the doc
1166/// defines the manifest dimensions — FS scope, network, subprocess,
1167/// credentials — but has no per-adapter table, so the assignments are
1168/// pinned inline here). Importers read repo-local files only; Context7 runs
1169/// an MCP server over stdio via npx (subprocess) that makes network calls.
1170// trace:v1 id=impl.crates-scc-cli-src-commands.adapter-scope work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
1171fn adapter_scope(name: &str) -> &'static str {
1172    match name {
1173        "context7" => "network+subprocess(npx)",
1174        "serena" | "beads" | "cbm" | "hindsight" | "scip" | "gitnexus" | "narsil" => "filesystem",
1175        _ => "filesystem",
1176    }
1177}
1178
1179/// Compute the configured-adapter scope listing from THE Integration
1180/// Registry: every row whose config key is enabled (or needs no config for
1181/// on-demand import), in fixed registry order. `narsil` never appears: it is
1182/// a `ccg` alias, resolved by `resolve_integration`. Both `scc adapters`
1183/// views read one registry, so they describe one universe.
1184// trace:v1 id=impl.crates-scc-cli-src-commands.configured-adapters work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1185fn configured_adapters(root: &Path) -> crate::Result<Vec<(String, &'static str)>> {
1186    use scc_indexer::adapters::IntegrationCategory;
1187    let config = load_config(root)?;
1188    let enabled = |key: Option<&str>| -> bool {
1189        match key {
1190            None => true,
1191            Some("serena") => config.integrations.serena,
1192            Some("beads") => config.integrations.beads,
1193            Some("hindsight") => config.integrations.hindsight,
1194            Some("gitnexus") => config.integrations.gitnexus,
1195            Some("context7_command") => !config.integrations.context7_command.is_empty(),
1196            // Unknown keys fail closed: a new config flag must be wired
1197            // here explicitly, never silently treated as enabled.
1198            Some(_) => false,
1199        }
1200    };
1201    Ok(scc_indexer::adapters::integration_registry()
1202        .into_iter()
1203        .filter(|d| {
1204            d.category != IntegrationCategory::CompatibilityOnly
1205                && d.category != IntegrationCategory::InternalPass
1206                && d.category != IntegrationCategory::AgentIntegration
1207                && enabled(d.config_key)
1208        })
1209        .map(|d| (d.id.to_string(), adapter_scope(d.id)))
1210        .collect())
1211}
1212
1213/// `scc adapters` — list enabled adapters with their declared capability
1214/// scope (security audit; docs/SECURITY.md §6). `--json` dumps the full
1215/// capability manifests instead.
1216// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-adapters work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
1217pub fn cmd_adapters(root: &Path, json: bool) -> crate::Result<()> {
1218    let manifests = scc_indexer::adapters::adapter_manifests();
1219    if json {
1220        println!("{}", serde_json::to_string_pretty(&manifests)?);
1221        return Ok(());
1222    }
1223    for (name, scope) in configured_adapters(root)? {
1224        println!("adapter: {name}  scope: {scope}");
1225    }
1226    Ok(())
1227}
1228
1229/// `scc update [--version V] [--dir D] [--dry-run]` — self-update the
1230/// binary through the same installer `docs/INSTALL.md` documents
1231/// (`scripts/install.sh`): checksum-verified download from the GitHub
1232/// release assets, then the binary's own smoke test.
1233///
1234/// The installer is fetched from the local release when `SCC_DOWNLOAD_BASE`
1235/// points at one (the contract test does this); otherwise it comes from
1236/// the published release path. This keeps `scc update` and the documented
1237/// install in agreement — one mechanism, not two.
1238///
1239/// Default install dir is the directory holding the running binary (so a
1240/// `~/.local/bin/scc` updates in place); `--dir` overrides it.
1241// trace:v1 id=impl.scc-cli-update work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1242pub fn cmd_update(version: Option<&str>, dir: Option<&std::path::Path>, dry_run: bool) -> crate::Result<()> {
1243    let exe = std::env::current_exe().map_err(|e| crate::CliError::Other(format!("cannot locate running binary: {e}")))?;
1244    let default_dir = exe.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| std::path::PathBuf::from("."));
1245    let target_dir = dir.map(|p| p.to_path_buf()).unwrap_or(default_dir);
1246    // Resolve `latest` through the releases API (the installer only knows
1247    // concrete versions); a pinned --version skips the lookup entirely.
1248    let resolved: String = match version {
1249        Some(v) => v.to_string(),
1250        None => latest_release_tag()?,
1251    };
1252    // The installer lives next to the release binaries, so it comes from
1253    // the same release path by construction. A `SCC_DOWNLOAD_BASE` fixture
1254    // (the contract test layout) resolves the same way — `scc update` and
1255    // the documented install stay one mechanism.
1256    let base = std::env::var("SCC_DOWNLOAD_BASE").unwrap_or_else(|_| {
1257        "https://github.com/carterlasalle/scc/releases/download".to_string()
1258    });
1259    let trimmed = base.trim_end_matches('/');
1260    let release_dir = trimmed.strip_suffix("/releases/download").map(|prefix| {
1261        format!("{prefix}/releases/download/v{resolved}")
1262    });
1263    let installer_url = match release_dir {
1264        Some(dir) => format!("{dir}/install.sh"),
1265        None => format!("{trimmed}/install.sh"),
1266    };
1267    let mut cmd = std::process::Command::new("sh");
1268    cmd.arg("-c").arg(format!(
1269        "curl -fsSL {url:?} | sh -s -- --version {ver} --dir {dir} {dry}",
1270        url = installer_url,
1271        ver = resolved,
1272        dir = target_dir.display(),
1273        dry = if dry_run { "--dry-run" } else { "" },
1274    ));
1275    let status = cmd.status().map_err(|e| crate::CliError::Other(format!("update failed to launch installer: {e}")))?;
1276    if !status.success() {
1277        return Err(crate::CliError::Other(format!("installer exited with status {status}")));
1278    }
1279    Ok(())
1280}
1281
1282// trace:exempt reason=internal-detail
1283fn latest_release_tag() -> crate::Result<String> {
1284    let api_base = std::env::var("SCC_API_BASE")
1285        .unwrap_or_else(|_| "https://api.github.com/repos/carterlasalle/scc".to_string());
1286    let url = format!("{}/releases/latest", api_base.trim_end_matches('/'));
1287    let out = std::process::Command::new("curl")
1288        .args(["-fsSL", "--max-time", "15", &url])
1289        .output()
1290        .map_err(|e| crate::CliError::Other(format!("latest-release lookup failed: {e}")))?;
1291    if !out.status.success() {
1292        return Err(crate::CliError::Other(format!("latest-release lookup failed: {url}")));
1293    }
1294    let v: serde_json::Value = serde_json::from_slice(&out.stdout)
1295        .map_err(|e| crate::CliError::Other(format!("latest-release response not JSON: {e}")))?;
1296    let tag = v.get("tag_name").and_then(|t| t.as_str()).unwrap_or("").trim_start_matches('v');
1297    if tag.is_empty() {
1298        return Err(crate::CliError::Other("latest release has no tag_name".to_string()));
1299    }
1300    Ok(tag.to_string())
1301}
1302
1303/// `scc timing [--all] [--last N] [--json]` — summarize extension spawn
1304/// timings from `~/.cache/scc/extension.log` (the same log `trace timing`
1305/// reads for tracelayer: per-command n/total/p50/p95/max).
1306///
1307/// Per-project by default (rows whose repo path is the current root or its
1308/// parent chain); `--all` summarizes every repository. `--last N` keeps
1309/// only the N most recent records. `--json` emits the machine-readable
1310/// report. Missing/empty log prints an empty table, never an error.
1311// trace:v1 id=impl.scc-cli-timing work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1312pub fn cmd_timing(root: &Path, all: bool, last: usize, json: bool) -> crate::Result<()> {
1313    let home = std::env::var("HOME").map_err(|e| crate::CliError::Other(format!("cannot locate HOME: {e}")))?;
1314    let log = std::path::PathBuf::from(home).join(".cache").join("scc").join("extension.log");
1315    let raw = std::fs::read_to_string(&log).unwrap_or_default();
1316    let mut rows: Vec<(String, String, f64, bool)> = Vec::new();
1317    for line in raw.lines() {
1318        let v: serde_json::Value = match serde_json::from_str(line) {
1319            Ok(v) => v,
1320            Err(_) => continue,
1321        };
1322        let event = v.get("event").and_then(|e| e.as_str()).unwrap_or("");
1323        if event == "inject" {
1324            // Token injections aggregate separately below; skip the
1325            // spawn-command grouping (no `cmd`/`ms` on these rows).
1326            continue;
1327        }
1328        if event != "spawn" {
1329            continue;
1330        }
1331        let ms = match v.get("ms").and_then(|m| m.as_f64()) {
1332            Some(m) => m,
1333            None => continue,
1334        };
1335        let cmd = v.get("cmd").and_then(|c| c.as_str()).unwrap_or("?").to_string();
1336        let repo = v.get("repo").and_then(|r| r.as_str()).unwrap_or("?").to_string();
1337        let ok = v.get("ok").and_then(|o| o.as_bool()).unwrap_or(true);
1338        rows.push((cmd, repo, ms, ok));
1339    }
1340    let root_str = root.to_string_lossy().to_string();
1341    if last > 0 && rows.len() > last {
1342        rows = rows.split_off(rows.len() - last);
1343    }
1344    if !all {
1345        rows.retain(|(_, repo, _, _)| repo == &root_str || root_str.starts_with(&format!("{repo}/")));
1346    }
1347    let mut groups: std::collections::BTreeMap<String, Vec<f64>> = std::collections::BTreeMap::new();
1348    let mut fails: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
1349    for (cmd, _, ms, ok) in &rows {
1350        // Group by the scc subcommand, not the full argv: `scc index
1351        // --paths a b` and `scc index --quiet` are one row.
1352        let short = cmd.strip_prefix("scc ").unwrap_or(cmd).split_whitespace().next().unwrap_or(cmd);
1353        let key = format!("scc {short}");
1354        groups.entry(key.clone()).or_default().push(*ms);
1355        if !ok {
1356            *fails.entry(key).or_default() += 1;
1357        }
1358    }
1359    let mut out: Vec<serde_json::Value> = Vec::new();
1360    for (cmd, mut ms) in groups {
1361        ms.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1362        let n = ms.len();
1363        let total: f64 = ms.iter().sum();
1364        let pct = |p: f64| ms[((p * n as f64).ceil() as usize).saturating_sub(1).min(n - 1)];
1365        out.push(serde_json::json!({
1366            "cmd": cmd, "n": n, "total_ms": (total * 10.0).round() / 10.0,
1367            "p50_ms": (pct(0.5) * 10.0).round() / 10.0,
1368            "p95_ms": (pct(0.95) * 10.0).round() / 10.0,
1369            "max_ms": ms[n - 1],
1370            "failed": fails.get(&cmd).cloned().unwrap_or(0),
1371        }));
1372    }
1373    out.sort_by(|a, b| {
1374        b.get("total_ms").and_then(|t| t.as_f64()).unwrap_or(0.0)
1375            .partial_cmp(&a.get("total_ms").and_then(|t| t.as_f64()).unwrap_or(0.0))
1376            .unwrap_or(std::cmp::Ordering::Equal)
1377    });
1378    if json {
1379        println!("{}", serde_json::to_string_pretty(&serde_json::json!({
1380            "log": log.display().to_string(), "repo": if all { None } else { Some(root_str) }, "commands": out,
1381        }))?);
1382        return Ok(());
1383    }
1384    if out.is_empty() {
1385        println!("no timing records ({}; use --all for every repository)", log.display());
1386        return Ok(());
1387    }
1388    println!("{:<28} {:>5} {:>10} {:>8} {:>8} {:>8} {:>6}", "command", "n", "total_ms", "p50", "p95", "max", "fail");
1389    for c in &out {
1390        println!("{:<28} {:>5} {:>10} {:>8} {:>8} {:>8} {:>6}",
1391            c.get("cmd").and_then(|v| v.as_str()).unwrap_or("?"),
1392            c.get("n").and_then(|v| v.as_u64()).unwrap_or(0),
1393            c.get("total_ms").and_then(|v| v.as_f64()).unwrap_or(0.0),
1394            c.get("p50_ms").and_then(|v| v.as_f64()).unwrap_or(0.0),
1395            c.get("p95_ms").and_then(|v| v.as_f64()).unwrap_or(0.0),
1396            c.get("max_ms").and_then(|v| v.as_f64()).unwrap_or(0.0),
1397            c.get("failed").and_then(|v| v.as_u64()).unwrap_or(0));
1398    }
1399    // Token injections (rtk-gain style): every hook firing logs its
1400    // startup/task/total tokens; here they sum per repo scope.
1401    let raw2 = std::fs::read_to_string(&log).unwrap_or_default();
1402    let mut injections = 0usize;
1403    let mut startup_tok = 0u64;
1404    let mut task_tok = 0u64;
1405    // Re-walk with the same scope filter: parse once, filter by repo.
1406    let all_rows: Vec<serde_json::Value> = raw2.lines().filter_map(|l| serde_json::from_str(l).ok()).collect();
1407    let scoped: Vec<&serde_json::Value> = if last > 0 && all_rows.len() > last {
1408        all_rows[all_rows.len() - last..].iter().collect()
1409    } else {
1410        all_rows.iter().collect()
1411    };
1412    for v in scoped {
1413        if v.get("event").and_then(|e| e.as_str()) != Some("inject") {
1414            continue;
1415        }
1416        let repo = v.get("repo").and_then(|r| r.as_str()).unwrap_or("?");
1417        if !all && !(repo == root_str.as_str() || root_str.starts_with(&format!("{repo}/"))) {
1418            continue;
1419        }
1420        injections += 1;
1421        startup_tok += v.get("startup_tokens").and_then(|t| t.as_u64()).unwrap_or(0);
1422        task_tok += v.get("task_tokens").and_then(|t| t.as_u64()).unwrap_or(0);
1423    }
1424    println!();
1425    if injections == 0 {
1426        println!("tokens injected: no hook firings recorded (update the extension, then prompt once)");
1427    } else {
1428        println!("tokens injected: {injections} hook firing(s), startup {startup_tok}, task {task_tok}, total {}",
1429            startup_tok + task_tok);
1430    }
1431    Ok(())
1432}
1433
1434/// `scc clean [--force]` — remove the index database (`.scc/scc.db`,
1435/// WAL/SHM sidecars) for the current repo so the next `scc index` is a
1436/// clean cold build. Config and plugins are untouched. Without `--force`,
1437/// prints the bytes to be freed and asks for confirmation.
1438// trace:v1 id=impl.scc-cli-clean work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1439pub fn cmd_clean(root: &Path, force: bool) -> crate::Result<()> {
1440    let db = root.join(".scc").join("scc.db");
1441    let sidecars = [".scc/scc.db-wal", ".scc/scc.db-shm"];
1442    let mut targets: Vec<std::path::PathBuf> = Vec::new();
1443    if db.exists() {
1444        targets.push(db.clone());
1445    }
1446    for s in sidecars {
1447        let p = root.join(s);
1448        if p.exists() {
1449            targets.push(p);
1450        }
1451    }
1452    if targets.is_empty() {
1453        println!("nothing to clean (no .scc/scc.db at {})", root.display());
1454        return Ok(());
1455    }
1456    let bytes: u64 = targets.iter().map(|p| std::fs::metadata(p).map(|m| m.len()).unwrap_or(0)).sum();
1457    if !force {
1458        println!("will remove {} file(s), freeing {} bytes:", targets.len(), bytes);
1459        for t in &targets {
1460            println!("  {}", t.display());
1461        }
1462        print!("proceed? [y/N] ");
1463        use std::io::Write;
1464        std::io::stdout().flush().map_err(|e| crate::CliError::Other(e.to_string()))?;
1465        let mut answer = String::new();
1466        std::io::stdin().read_line(&mut answer).map_err(|e| crate::CliError::Other(e.to_string()))?;
1467        if !matches!(answer.trim().to_lowercase().as_str(), "y" | "yes") {
1468            println!("aborted");
1469            return Ok(());
1470        }
1471    }
1472    for t in &targets {
1473        std::fs::remove_file(t).map_err(|e| crate::CliError::Other(format!("cannot remove {}: {e}", t.display())))?;
1474    }
1475    println!("removed {} file(s), freed {} bytes; next `scc index` is a cold build", targets.len(), bytes);
1476    Ok(())
1477}
1478
1479/// `scc doctor` — integration health from THE Integration Registry
1480/// (`scc_indexer::adapters::integration_registry`), offline, read-only and
1481/// fast. Default never touches the network and never spawns a subprocess:
1482/// every row is answered from local state only (config flags, binary on
1483/// PATH, files present, evidence already in the graph). `--deep` may start
1484/// LOCAL subprocesses (pyright/tsserver handshakes); `--network` may test
1485/// remote endpoints (Context7). `--strict` turns warnings into a nonzero
1486/// exit; `--json` emits the machine-readable report.
1487///
1488/// Honesty rule: a row reports IMPLEMENTED / CONFIGURED / REACHABLE /
1489/// CONTRIBUTING separately. A config boolean never implies contribution —
1490/// contribution means facts of that integration's evidence kind are actually
1491/// in the graph (or a handshake succeeded under --deep/--network).
1492// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-doctor work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1493pub fn cmd_doctor(root: &Path, json: bool, deep: bool, network: bool, strict: bool) -> crate::Result<bool> {
1494    let store = open_store(root)?;
1495    let config = load_config(root)?;
1496    let rep = scc_engine::integrations::doctor_report(&store, &config, root, deep, network).map_err(engine_err)?;
1497    let rows = rep.integrations;
1498    let agent_rows = rep.agents;
1499    let mut warnings = rep.warnings;
1500    let (scc_version, rev, stale_len, dangling, stats) =
1501        (rep.scc_version, rep.revision, rep.stale_files, rep.dangling_edges, rep.stats);
1502    if json {
1503        println!(
1504            "{}",
1505            serde_json::to_string_pretty(&serde_json::json!({
1506                "scc": scc_version,
1507                "revision": rev,
1508                "stale_files": stale_len,
1509                "dangling_edges": dangling,
1510                "stats": stats,
1511                "integrations": rows,
1512                "agents": agent_rows.iter().map(|a| serde_json::json!({"id": a.id, "present": a.present, "detail": a.detail})).collect::<Vec<_>>(),
1513                "warnings": warnings,
1514            }))?
1515        );
1516    } else {
1517        println!("SCC Doctor");
1518        println!("==========");
1519        println!();
1520        println!("CORE");
1521        println!("  scc        {scc_version}");
1522        println!("  revision   {rev}");
1523        println!("  stale      {} file(s)", stale_len);
1524        println!("  dangling   {dangling} edge(s) (see `scc check-invariants`)");
1525        println!("  entities   {}", stats.get("entities").copied().unwrap_or(0));
1526        println!();
1527        println!("INTEGRATIONS");
1528        for r in &rows {
1529            let mark = if r.contributing {
1530                "✓"
1531            } else if r.configured {
1532                "!"
1533            } else {
1534                "-"
1535            };
1536            let reach = match r.reachable {
1537                Some(true) => "reachable; ",
1538                Some(false) => "UNREACHABLE; ",
1539                None => "",
1540            };
1541            println!("  {mark} {:<12} [{:<18}] {}{}", r.id, r.category, reach, r.detail);
1542        }
1543        println!();
1544        println!("AGENTS");
1545        for a in &agent_rows {
1546            println!("  {} {:<12} {}", if a.present { "✓" } else { "-" }, a.id, a.detail);
1547        }
1548        println!();
1549        if warnings == 0 {
1550            println!("SUMMARY  PASS ({} integrations contributing evidence)", rows.iter().filter(|r| r.contributing).count());
1551        } else {
1552            println!("SUMMARY  {warnings} warning(s); run `scc doctor --deep` / `--network` for handshake detail");
1553        }
1554    }
1555    if dangling > 0 {
1556        warnings += 1;
1557    }
1558    // `scc check-invariants` remains the CI gate for graph integrity;
1559    // doctor reports health (warnings) by default and fails only on
1560    // --strict, so informational runs stay exit-0.
1561    Ok(!(strict && warnings > 0))
1562}
1563
1564/// `scc lessons add <text>` — append one durable lesson to
1565/// `<root>/.scc/lessons.jsonl` (the Hindsight memory bank). The bank is
1566/// ingested into the System IR with `scc import hindsight .scc/lessons.jsonl`.
1567// 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
1568pub fn cmd_lessons_add(root: &Path, text: &str) -> crate::Result<()> {
1569    let dir = scc_dir(root);
1570    std::fs::create_dir_all(&dir)?;
1571    let path = dir.join("lessons.jsonl");
1572    let n = std::fs::read_to_string(&path)
1573        .map(|s| s.lines().filter(|l| !l.trim().is_empty()).count())
1574        .unwrap_or(0);
1575    let id = format!("lesson-{}", n + 1);
1576    let record = serde_json::json!({
1577        "id": id,
1578        "text": text,
1579        "created_at": scc_core::now_rfc3339(),
1580    });
1581    let mut f = std::fs::OpenOptions::new()
1582        .create(true)
1583        .append(true)
1584        .open(&path)
1585        .map_err(|e| crate::CliError::Other(format!("lessons: {e}")))?;
1586    writeln!(f, "{record}").map_err(|e| crate::CliError::Other(format!("lessons: {e}")))?;
1587    println!(
1588        "appended {id} to {} (ingest with `scc import hindsight .scc/lessons.jsonl`)",
1589        path.display()
1590    );
1591    Ok(())
1592}
1593
1594/// `scc lessons` — list stored lessons from the System IR (most important
1595/// first, same ordering as the context-pack enrichment).
1596// 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
1597pub fn cmd_lessons_list(root: &Path, limit: usize) -> crate::Result<()> {
1598    let lessons = scc_engine::state::lessons_list(root, limit).map_err(engine_err)?;
1599    if lessons.is_empty() {
1600        println!("no lessons in the store — run `scc lessons add \"...\"` then `scc import hindsight .scc/lessons.jsonl`");
1601        return Ok(());
1602    }
1603    for (content, tags) in lessons {
1604        let tag_str = if tags.is_empty() {
1605            String::new()
1606        } else {
1607            format!(" [{}]", tags.join(", "))
1608        };
1609        println!("- {content}{tag_str}");
1610    }
1611    Ok(())
1612}
1613
1614/// `scc beads` — list active (in-progress) tasks from `.beads/issues.jsonl`
1615/// (task state, not system facts).
1616// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-beads work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
1617pub fn cmd_beads(root: &Path) -> crate::Result<()> {
1618    let active = scc_engine::state::beads(root, 20).map_err(engine_err)?;
1619    if active.is_empty() {
1620        println!("no active beads tasks (checked .beads/issues.jsonl)");
1621        return Ok(());
1622    }
1623    println!("active beads tasks:");
1624    for t in active {
1625        println!("- {t}");
1626    }
1627    Ok(())
1628}
1629
1630// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-import work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
1631pub fn cmd_import(root: &Path, format: &str, file: &str) -> crate::Result<()> {
1632    let report = scc_engine::state::import_evidence(root, format, file).map_err(engine_err)?;
1633    // P0: imported evidence changes system truth — the engine already bumped
1634    // the evidence epoch and recompiled the derived layer.
1635    println!(
1636        "imported {} symbols, {} calls, {} imports ({} errors); evidence epoch bumped, derived layer recompiled",
1637        report.symbols, report.calls, report.imports, report.errors
1638    );
1639    Ok(())
1640}
1641
1642// 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
1643pub fn cmd_runtime_status(root: &Path, json: bool) -> crate::Result<()> {
1644    let edges = scc_engine::state::runtime_edges(root).map_err(engine_err)?;
1645    if json {
1646        println!("{}", serde_json::to_string_pretty(&edges)?);
1647        return Ok(());
1648    }
1649    if edges.is_empty() {
1650        println!("no runtime observations ingested");
1651    }
1652    let total: u64 = edges.iter().map(|e| e.count).sum();
1653    let errs: u64 = edges.iter().map(|e| e.errors).sum();
1654    println!("{} observed edge(s), {} observations, {} error(s)", edges.len(), total, errs);
1655    for e in &edges {
1656        println!(
1657            "- {} → {} ×{} (avg {:.1} ms, {} err, last {})",
1658            e.source, e.target, e.count, e.latency_ms, e.errors, e.last_observed
1659        );
1660    }
1661    Ok(())
1662}
1663
1664// 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
1665pub fn cmd_runtime_reconcile(root: &Path, json: bool) -> crate::Result<()> {
1666    let rec = scc_engine::state::reconcile(root).map_err(engine_err)?;
1667    if json {
1668        println!("{}", serde_json::to_string_pretty(&rec)?);
1669        return Ok(());
1670    }
1671    println!("static-vs-observed reconciliation");
1672    println!("  matched:             {}", rec.matched.len());
1673    println!("  observed not static: {}", rec.observed_not_static.len());
1674    println!("  static not observed: {}", rec.static_not_observed.len());
1675    // trace:inherit impl.crates-scc-cli-src-commands.cmd-runtime-reconcile reason=detail-cap-note
1676    const MAX_RECONCILE_DETAIL: usize = 20;
1677    let cap = |label: &str, items: &[String]| {
1678        for e in items.iter().take(MAX_RECONCILE_DETAIL) {
1679            println!("  [{label}] {e}");
1680        }
1681        if items.len() > MAX_RECONCILE_DETAIL {
1682            println!(
1683                "  …and {} more [{label}] (counts exact; --json for the full list)",
1684                items.len() - MAX_RECONCILE_DETAIL
1685            );
1686        }
1687    };
1688    cap("matched", &rec.matched);
1689    cap("runtime-only", &rec.observed_not_static);
1690    cap("static-only", &rec.static_not_observed);
1691    Ok(())
1692}
1693
1694/// `scc diagram` — SCC-native architecture diagram (SPEC-SCC-VIEWER §1).
1695/// L1 nodes plus capped architectural edges plus flow subgraphs, as Mermaid
1696/// (default) or dependency-free SVG. Deterministic: same index, same bytes.
1697// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-diagram work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
1698pub fn cmd_diagram(root: &Path, format: &str, out: Option<&str>) -> crate::Result<()> {
1699    let store = open_store(root)?;
1700    if store.snapshot_status()?.is_none() {
1701        return Err(crate::CliError::Other("not indexed yet — run `scc index`".into()));
1702    }
1703    // Registry derivation: the engine owns the model + rendering; the CLI
1704    // parses args, writes files, and prints (spec section 2). The store is
1705    // already open for the staleness check — derive directly instead of
1706    // re-entering through invoke (which would reopen it).
1707    let v = scc_engine::exports::diagram(&store, format).map_err(engine_err)?;
1708    let text = v.get("text").and_then(|t| t.as_str()).unwrap_or("");
1709    if let Some(path) = out {
1710        std::fs::write(path, text)?;
1711        println!(
1712            "diagram: {} nodes, {} edges, {} flows -> {path}",
1713            v.get("nodes").and_then(|n| n.as_u64()).unwrap_or(0),
1714            v.get("edges").and_then(|n| n.as_u64()).unwrap_or(0),
1715            v.get("flows").and_then(|n| n.as_u64()).unwrap_or(0),
1716        );
1717    } else {
1718        print!("{text}");
1719    }
1720    Ok(())
1721}
1722
1723/// `scc snap` — Snapcompact-style bitmap export (SPEC-SCC-VIEWER §3).
1724/// Default OFF: requires `--out` text plus `--png` (or config
1725/// `context.snap_enabled`) to render; otherwise prints the map text and the
1726/// pinned Pillow recipe so the operator sees exactly what would ship.
1727// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-snap work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
1728pub fn cmd_snap(
1729    root: &Path,
1730    out: Option<&str>,
1731    max_chars: usize,
1732    png: Option<&str>,
1733    color: bool,
1734) -> crate::Result<()> {
1735    let store = open_store(root)?;
1736    if store.snapshot_status()?.is_none() {
1737        return Err(crate::CliError::Other("not indexed yet — run `scc index`".into()));
1738    }
1739    let config = load_config(root)?;
1740    let map = crate::viewer::map_text(&store, max_chars)?;
1741    let rows = map.lines().count();
1742    let (text_tokens, image_tokens) = crate::viewer::token_estimate(map.len(), rows);
1743    let render = png.is_some() || config.context.snap_enabled;
1744    if let Some(path) = out {
1745        std::fs::write(path, &map)?;
1746    } else {
1747        print!("{map}");
1748        if !map.ends_with('\n') {
1749            println!();
1750        }
1751    }
1752    if color {
1753        eprintln!("note: --color is accepted for recipe parity; the pinned recipe renders monochrome unless edited");
1754    }
1755    println!("snap: {rows} rows, {} chars — text ~{text_tokens} tokens vs PNG ~{image_tokens} image tokens", map.len());
1756    if render {
1757        let dest = png.unwrap_or("scc-snap.png");
1758        render_snap_png(&map, dest)?;
1759        println!("snap png -> {dest}");
1760    } else {
1761        println!("render OFF by default (set context.snap_enabled=true or pass --png <file>); recipe:");
1762        println!("{}", crate::viewer::snap_recipe());
1763    }
1764    Ok(())
1765}
1766
1767/// Render the map text to PNG via the pinned Pillow recipe. Shells out to
1768/// `python3` only when rendering was explicitly requested; falls back to
1769/// printing the recipe path when Pillow is missing.
1770// trace:v1 id=impl.crates-scc-cli-src-commands.render-snap-png work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
1771fn render_snap_png(map: &str, dest: &str) -> crate::Result<()> {
1772    // Fail fast with an actionable error when Pillow is missing (CI has no
1773    // PIL): the operator installs Pillow or uses the printed recipe.
1774    let probe = std::process::Command::new("python3")
1775        .arg("-c")
1776        .arg("import PIL")
1777        .output()
1778        .map_err(|e| crate::CliError::Other(format!("snap: cannot run python3 ({e})")))?;
1779    if !probe.status.success() {
1780        return Err(crate::CliError::Other(
1781            "snap: python3 has no Pillow (pip install pillow); map text written, recipe printed above".into(),
1782        ));
1783    }
1784    let dir = tempfile::TempDir::new()?;
1785    let map_path = dir.path().join("map.txt");
1786    let recipe_path = dir.path().join("snap.py");
1787    std::fs::write(&map_path, map)?;
1788    std::fs::write(&recipe_path, crate::viewer::snap_recipe())?;
1789    let out = std::process::Command::new("python3")
1790        .arg(&recipe_path)
1791        .arg(&map_path)
1792        .arg(dest)
1793        .output()
1794        .map_err(|e| crate::CliError::Other(format!("snap: cannot run python3 ({e}); recipe printed above")))?;
1795    if !out.status.success() {
1796        return Err(crate::CliError::Other(format!(
1797            "snap: Pillow render failed: {}",
1798            String::from_utf8_lossy(&out.stderr).trim()
1799        )));
1800    }
1801    Ok(())
1802}
1803
1804/// `scc view` — local web viewer (SPEC-SCC-VIEWER §2). Serves the loopback
1805/// viewer routes on an ephemeral port (or `--port`), then opens the
1806/// browser unless `--no-open`.
1807// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-view work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
1808pub fn cmd_view(root: &Path, port: Option<u16>, no_open: bool) -> crate::Result<()> {
1809    let store = open_store(root)?;
1810    if store.snapshot_status()?.is_none() {
1811        return Err(crate::CliError::Other("not indexed yet — run `scc index`".into()));
1812    }
1813    let addr = match port {
1814        Some(p) => format!("127.0.0.1:{p}"),
1815        None => "127.0.0.1:0".to_string(),
1816    };
1817    let server = tiny_http::Server::http(&addr)
1818        .map_err(|e| crate::CliError::Other(format!("cannot bind {addr}: {e}")))?;
1819    let bound = server.server_addr();
1820    let url = format!("http://{bound}/");
1821    println!("scc view: {url} (root {})", root.display());
1822    if !no_open {
1823        #[cfg(target_os = "macos")]
1824        let _ = std::process::Command::new("open").arg(&url).spawn();
1825        #[cfg(target_os = "linux")]
1826        let _ = std::process::Command::new("xdg-open").arg(&url).spawn();
1827    }
1828    for request in server.incoming_requests() {
1829        let url_path = request.url().to_string();
1830        let method = request.method().clone();
1831        if method == tiny_http::Method::Get && crate::viewer::is_viewer_path(&url_path) {
1832            let store = match open_store(root) {
1833                Ok(s) => s,
1834                Err(e) => {
1835                    let _ = request.respond(
1836                        tiny_http::Response::from_string(format!("store error: {e}"))
1837                            .with_status_code(500),
1838                    );
1839                    continue;
1840                }
1841            };
1842            let (status, body) = crate::viewer::serve_viewer(&store, &url_path);
1843            let response = tiny_http::Response::from_string(body)
1844                .with_status_code(status)
1845                .with_header(
1846                    tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
1847                        .unwrap(),
1848                );
1849            let _ = request.respond(response);
1850            continue;
1851        }
1852        // Viewer-only server: the daemon (`scc serve`) owns /v1/*.
1853        // Anything else is a 404 so typos fail loudly, not silently.
1854        let _ = request.respond(
1855            tiny_http::Response::from_string("no such viewer route (scc serve owns /v1/*)")
1856                .with_status_code(404),
1857        );
1858    }
1859    Ok(())
1860}
1861
1862/// `scc rpc --stdio`: structured JSON-RPC (SDK subprocess mode).
1863// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-rpc work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1864pub fn cmd_rpc(root: &Path, _stdio: bool) -> crate::Result<()> {
1865    scc_engine::rpc::serve_stdio(root).map_err(engine_err)
1866}
1867
1868/// `scc operations [--describe ID]`: introspection over the registry.
1869// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-operations work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1870pub fn cmd_operations(describe: Option<&str>) -> crate::Result<()> {
1871    if let Some(id) = describe {
1872        match scc_engine::ops::describe(id) {
1873            Some(d) => {
1874                println!("{}", serde_json::to_string_pretty(&d)?);
1875                match scc_engine::ops::input_schema(id) {
1876                    Some(s) => println!("{}", serde_json::to_string_pretty(&s)?),
1877                    None => println!("(no typed input schema: free-form object)"),
1878                }
1879            }
1880            None => println!("unknown operation '{id}' (see `scc operations`)"),
1881        }
1882        return Ok(());
1883    }
1884    println!("Operation                 Mutation       Stream  Stability     Description");
1885    println!("--------------------------------------------------------------------------------");
1886    for d in scc_engine::ops::OPERATIONS {
1887        println!("{:<26} {:<14} {:<7} {:<13} {}", d.id, format!("{:?}", d.mutation), d.streaming, format!("{:?}", d.stability), d.description);
1888    }
1889    Ok(())
1890}
1891
1892/// `scc plugin list`: enabled plugins with lock entries (engine owns the set).
1893// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-plugin-list work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1894pub fn cmd_plugin_list(root: &Path) -> crate::Result<()> {
1895    let out = scc_engine::invoke(root, "plugins.list", serde_json::json!({})).map_err(engine_err)?;
1896    println!("{}", serde_json::to_string_pretty(&out)?);
1897    Ok(())
1898}
1899
1900/// `scc plugin describe <id>`.
1901// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-plugin-describe work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1902pub fn cmd_plugin_describe(root: &Path, id: &str) -> crate::Result<()> {
1903    let out = scc_engine::invoke(root, "plugins.describe", serde_json::json!({"id": id})).map_err(engine_err)?;
1904    println!("{}", serde_json::to_string_pretty(&out)?);
1905    Ok(())
1906}
1907
1908/// `scc plugin doctor`.
1909// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-plugin-doctor work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1910pub fn cmd_plugin_doctor(root: &Path) -> crate::Result<()> {
1911    let out = scc_engine::invoke(root, "plugins.doctor", serde_json::json!({})).map_err(engine_err)?;
1912    println!("{}", serde_json::to_string_pretty(&out)?);
1913    Ok(())
1914}
1915
1916/// `scc plugin graph`: render the deterministic extension order.
1917/// Engine owns derivation (`plugins.graph`); CLI prints text.
1918// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-plugin-graph work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1919pub fn cmd_plugin_graph(root: &Path) -> crate::Result<()> {
1920    let out = scc_engine::invoke(root, "plugins.graph", serde_json::json!({})).map_err(engine_err)?;
1921    let groups = out.get("groups").and_then(|g| g.as_object());
1922    match groups {
1923        Some(g) if !g.is_empty() => {
1924            for (ty, items) in g {
1925                println!("{ty}:");
1926                for it in items.as_array().cloned().unwrap_or_default() {
1927                    println!("  {} (priority {})", it.get("key").and_then(|k| k.as_str()).unwrap_or("?"), it.get("priority").and_then(|p| p.as_i64()).unwrap_or(0));
1928                }
1929            }
1930        }
1931        _ => println!("(no extensions registered)"),
1932    }
1933    Ok(())
1934}
1935
1936/// `scc plugin lock`: write .scc/plugins.lock from the live set.
1937// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-plugin-lock work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1938pub fn cmd_plugin_lock(root: &Path) -> crate::Result<()> {
1939    let out = scc_engine::invoke(root, "plugins.lock", serde_json::json!({})).map_err(engine_err)?;
1940    println!("{}", serde_json::to_string_pretty(&out)?);
1941    Ok(())
1942}
1943
1944/// `scc plugin check`: verify live plugins against .scc/plugins.lock.
1945// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-plugin-check work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1946pub fn cmd_plugin_check(root: &Path) -> crate::Result<()> {
1947    let out = scc_engine::invoke(root, "plugins.check", serde_json::json!({})).map_err(engine_err)?;
1948    println!("{}", serde_json::to_string_pretty(&out)?);
1949    if out.get("ok").and_then(|v| v.as_bool()) != Some(true) {
1950        return Err(crate::CliError::Other(format!(
1951            "plugin lock drift: {}",
1952            out.get("drift").map(|v| v.to_string()).unwrap_or_default()
1953        )));
1954    }
1955    Ok(())
1956}
1957
1958/// `scc plugin enable <id>` / `scc plugin disable <id>`: project
1959/// allow-list in .scc/config.yaml. Engine owns the mutation; CLI renders.
1960// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-plugin-enable-disable work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1961pub fn cmd_plugin_enable(root: &Path, id: &str) -> crate::Result<()> {
1962    let out = scc_engine::invoke(root, "plugins.enable", serde_json::json!({"id": id})).map_err(engine_err)?;
1963    println!("{}", serde_json::to_string_pretty(&out)?);
1964    Ok(())
1965}
1966
1967// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-plugin-disable work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1968pub fn cmd_plugin_disable(root: &Path, id: &str) -> crate::Result<()> {
1969    let out = scc_engine::invoke(root, "plugins.disable", serde_json::json!({"id": id})).map_err(engine_err)?;
1970    println!("{}", serde_json::to_string_pretty(&out)?);
1971    Ok(())
1972}
1973
1974/// `scc plugin invoke <operation> [json-input]`.
1975// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-plugin-invoke work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1976pub fn cmd_plugin_invoke(root: &Path, operation: &str, input: &str) -> crate::Result<()> {
1977    let input: serde_json::Value = serde_json::from_str(input).unwrap_or(serde_json::json!({}));
1978    let out = scc_engine::invoke(root, operation, input).map_err(engine_err)?;
1979    println!("{}", serde_json::to_string_pretty(&out)?);
1980    Ok(())
1981}
1982
1983#[cfg(test)]
1984mod tests {
1985
1986    #[test]
1987    // trace:exempt reason=unit-test
1988    fn ensure_scc_ignored_keeps_intent_committable() {
1989        let dir = tempfile::TempDir::new().unwrap();
1990        let root = dir.path();
1991        crate::ensure_scc_ignored(root);
1992        let gi = std::fs::read_to_string(root.join(".gitignore")).unwrap();
1993        assert!(gi.contains(".scc/*"), "{gi}");
1994        assert!(gi.contains("!.scc/intent.yaml"), "{gi}");
1995        assert!(gi.contains("!.scc/plugins.toml"), "{gi}");
1996        assert!(gi.contains("!.scc/plugins.lock"), "{gi}");
1997        crate::ensure_scc_ignored(root);
1998        let gi2 = std::fs::read_to_string(root.join(".gitignore")).unwrap();
1999        assert_eq!(gi, gi2, "idempotent");
2000        std::fs::write(root.join(".gitignore"), "target/\n.scc/\n").unwrap();
2001        crate::ensure_scc_ignored(root);
2002        let gi3 = std::fs::read_to_string(root.join(".gitignore")).unwrap();
2003        assert!(gi3.contains("target/"), "{gi3}");
2004        assert!(!gi3.lines().any(|l| l.trim() == ".scc/"), "{gi3}");
2005        assert!(gi3.contains("!.scc/intent.yaml"), "{gi3}");
2006    }
2007
2008    #[test]
2009    // trace:exempt reason=unit-test
2010    fn detect_harnesses_finds_bins_dirs_and_project_dirs() {
2011        let home = tempfile::TempDir::new().unwrap();
2012        let bindir = home.path().join("bin");
2013        std::fs::create_dir_all(&bindir).unwrap();
2014        std::fs::write(bindir.join("codex"), "#!/bin/sh\n").unwrap();
2015        std::fs::create_dir_all(home.path().join(".config").join("opencode")).unwrap();
2016        let root = tempfile::TempDir::new().unwrap();
2017        std::fs::create_dir_all(root.path().join(".omp")).unwrap();
2018        let found = detect_harnesses(
2019            home.path(),
2020            &[bindir],
2021            root.path(),
2022        );
2023        assert!(found.contains(&SetupHarness::Codex), "codex via PATH: {found:?}");
2024        assert!(found.contains(&SetupHarness::Opencode), "opencode via config dir: {found:?}");
2025        assert!(found.contains(&SetupHarness::Omp), "omp via project dir: {found:?}");
2026        assert!(!found.contains(&SetupHarness::Claude), "no claude present: {found:?}");
2027        assert!(!found.contains(&SetupHarness::Pi), "no pi present: {found:?}");
2028    }
2029
2030    #[test]
2031    // trace:exempt reason=unit-test
2032    fn detect_harnesses_empty_when_nothing_present() {
2033        let home = tempfile::TempDir::new().unwrap();
2034        let root = tempfile::TempDir::new().unwrap();
2035        let found = detect_harnesses(home.path(), &[], root.path());
2036        assert!(found.is_empty(), "{found:?}");
2037    }
2038
2039    use super::*;
2040
2041
2042    #[test]
2043// 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
2044    fn lessons_add_appends_jsonl_lines() {
2045        let dir = tempfile::TempDir::new().unwrap();
2046        let root = dir.path().join("repo");
2047        std::fs::create_dir_all(&root).unwrap();
2048        cmd_lessons_add(&root, "first lesson").unwrap();
2049        cmd_lessons_add(&root, "second lesson").unwrap();
2050        let path = root.join(".scc/lessons.jsonl");
2051        let text = std::fs::read_to_string(&path).unwrap();
2052        let lines: Vec<&str> = text.lines().collect();
2053        assert_eq!(lines.len(), 2, "a second add must append, not overwrite");
2054        let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
2055        assert_eq!(first["id"], "lesson-1");
2056        assert_eq!(first["text"], "first lesson");
2057        assert!(first["created_at"].is_string());
2058        let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
2059        assert_eq!(second["id"], "lesson-2");
2060        assert_eq!(second["text"], "second lesson");
2061        // the shape must be ingestable by the hindsight adapter
2062        let lesson: scc_indexer::adapters::hindsight::Lesson =
2063            serde_json::from_str(lines[0]).unwrap();
2064        assert_eq!(lesson.body(), "first lesson");
2065    }
2066
2067    #[test]
2068// 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
2069    fn adapters_lists_configured_scope() {
2070        let dir = tempfile::TempDir::new().unwrap();
2071        let root = dir.path().join("repo");
2072        std::fs::create_dir_all(root.join(".scc")).unwrap();
2073        std::fs::write(
2074            root.join(".scc/config.yaml"),
2075            "schema: 1\nintegrations:\n  beads: true\n  context7_command: \"npx -y @upstash/context7-mcp\"\n",
2076        )
2077        .unwrap();
2078        let listing = configured_adapters(&root).unwrap();
2079        assert!(
2080            listing.contains(&("beads".to_string(), "filesystem")),
2081            "{listing:?}"
2082        );
2083        assert!(
2084            listing.contains(&("context7".to_string(), "network+subprocess(npx)")),
2085            "{listing:?}"
2086        );
2087        assert!(
2088            !listing.iter().any(|(n, _)| n == "hindsight"),
2089            "disabled integrations must not be listed: {listing:?}"
2090        );
2091        // scip/cbm are always-available on-demand importers
2092        assert!(listing.iter().any(|(n, _)| n == "scip"), "{listing:?}");
2093        assert!(listing.iter().any(|(n, _)| n == "cbm"), "{listing:?}");
2094        // One universe: every listed adapter resolves in the registry, and
2095        // the registry's evidence importers appear (tracelayer was missing).
2096        for (n, _) in &listing {
2097            assert!(
2098                scc_indexer::adapters::resolve_integration(n).is_some(),
2099                "listed adapter {n} must resolve in the registry: {listing:?}"
2100            );
2101        }
2102        assert!(listing.iter().any(|(n, _)| n == "tracelayer"), "{listing:?}");
2103        // narsil is a ccg alias, never a listed adapter.
2104        assert!(!listing.iter().any(|(n, _)| n == "narsil"), "{listing:?}");
2105        assert!(listing.iter().any(|(n, _)| n == "ccg"), "{listing:?}");
2106        // cmd_adapters renders the exact expected line format
2107        cmd_adapters(&root, false).unwrap();
2108        // doctor is offline, read-only, exit-0 by default on this fixture.
2109        assert!(cmd_doctor(&root, false, false, false, false).unwrap(), "doctor must pass clean");
2110        assert!(cmd_doctor(&root, true, false, false, false).unwrap(), "doctor --json must pass clean");
2111    }
2112}
2113