Skip to main content

scc_engine/
invoke.rs

1//! Dynamic operation dispatch: `invoke(id, input_json) -> output_json`.
2//!
3//! The SAME fns the typed namespaces call — one implementation, two
4//! interfaces (spec §4). Transports (RPC/HTTP/SDK/FFI) go through here;
5//! the CLI keeps its typed calls. Outputs are the canonical types
6//! serialized — never re-modelled strings.
7
8use serde_json::Value;
9
10// trace:exempt reason=internal-detail
11pub fn invoke(
12    root: &std::path::Path,
13    operation: &str,
14    input: Value,
15) -> crate::Result<Value> {
16    let store = crate::workspace::open_store(root)?;
17    let config = crate::workspace::load_config(root)?;
18    let stale = crate::workspace::stale_paths(&store)?;
19    let engine = crate::workspace::open_engine(&store, &config, stale)?;
20    let ctx = engine.context();
21    let out = match operation {
22        "workspace.status" => {
23            let s = status_value(&store)?;
24            serde_json::to_value(&s)?
25        }
26        "workspace.languages" => Value::String(scc_core::support_matrix_markdown()),
27        "context.overview" => serde_json::to_value(ctx.overview()?)?,
28        "context.atlas" => {
29            let budget: Option<usize> = input.get("budget").and_then(|v| v.as_u64()).map(|v| v as usize);
30            let full: bool = input.get("full").and_then(|v| v.as_bool()).unwrap_or(false);
31            let unbounded: bool = input.get("unbounded").and_then(|v| v.as_bool()).unwrap_or(false);
32            serde_json::to_value(ctx.atlas(budget, full, unbounded)?)?
33        }
34        "context.startup" => {
35            let req: scc_api::StartupRequest = serde_json::from_value(input).unwrap_or(scc_api::StartupRequest { budget: None });
36            // Structured triple: text + budget + artifact. Transports that
37            // need a bare string take `.text`; nothing re-derives startup.
38            let (startup, text) = ctx.startup(&req)?;
39            let budget = scc_context::startup::allocate_startup_budget(&ctx.engine.ctx(), req.budget);
40            serde_json::json!({"text": text, "budget": budget, "artifact": startup.artifact})
41        }
42        "context.task" => {
43            let req: scc_api::TaskContextRequest = serde_json::from_value(input)?;
44            let (scorer, reranker) = crate::inference::rankers(&store, &config, &req.goal);
45            let scorer_trait: Option<&dyn scc_context::rank::SemanticScorer> =
46                scorer.as_ref().map(|s| s as &dyn scc_context::rank::SemanticScorer);
47            let reranker_trait: Option<&dyn scc_context::rank::Reranker> =
48                reranker.as_ref().map(|r| r as &dyn scc_context::rank::Reranker);
49            serde_json::to_value(crate::task::build_task_context(&engine, &config, root, &req, scorer_trait, reranker_trait)?)?
50        }
51        "context.task_pack" => {
52            let req: scc_api::TaskContextRequest = serde_json::from_value(input)?;
53            let (scorer, reranker) = crate::inference::rankers(&store, &config, &req.goal);
54            let scorer_trait: Option<&dyn scc_context::rank::SemanticScorer> =
55                scorer.as_ref().map(|s| s as &dyn scc_context::rank::SemanticScorer);
56            let reranker_trait: Option<&dyn scc_context::rank::Reranker> =
57                reranker.as_ref().map(|r| r as &dyn scc_context::rank::Reranker);
58            serde_json::to_value(crate::task::build_enriched_task_pack(&engine, &config, root, &req, scorer_trait, reranker_trait)?)?
59        }
60        "context.subagent" => {
61            let goal = input.get("goal").and_then(|v| v.as_str()).unwrap_or("");
62            let files: Vec<String> = input.get("files").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect()).unwrap_or_default();
63            let symbols: Vec<String> = input.get("symbols").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect()).unwrap_or_default();
64            let budget = input.get("budget").and_then(|v| v.as_u64()).map(|b| b as usize);
65            serde_json::to_value(ctx.subagent(goal, &files, &symbols, budget)?)?
66        }
67        "context.compress" => {
68            // Pack-only compression ladder, no external summarizer, no delta,
69            // no ledger: same derivation the CLI uses with --cmd omitted.
70            let goal = input.get("goal").and_then(|v| v.as_str()).unwrap_or("");
71            let budget = input.get("budget").and_then(|v| v.as_u64()).map(|b| b as usize);
72            let (scorer, reranker) = crate::inference::rankers(&store, &config, goal);
73            let scorer_trait: Option<&dyn scc_context::rank::SemanticScorer> =
74                scorer.as_ref().map(|s| s as &dyn scc_context::rank::SemanticScorer);
75            let reranker_trait: Option<&dyn scc_context::rank::Reranker> =
76                reranker.as_ref().map(|r| r as &dyn scc_context::rank::Reranker);
77            let pack = crate::task::build_enriched_task_pack(&engine, &config, root, &scc_api::TaskContextRequest { goal: goal.into(), files: vec![], symbols: vec![], budget, hook: false }, scorer_trait, reranker_trait)?;
78            serde_json::to_value(pack)?
79        }
80        "context.component" | "context.flow" | "context.impact" | "context.verify" | "context.structural" | "source.structural" | "surface.build" => {
81            invoke_context(&ctx, &store, &config, operation, input)?
82        }
83        "graph.query" => {
84            let req: scc_api::QueryRequest = serde_json::from_value(input)?;
85            let hit = crate::graph::query(&store, &req)?;
86            serde_json::json!({
87                "entities": hit.entities,
88                "symbols": hit.symbols.iter().map(|(n, s, k, f, l)| serde_json::json!({"name": n, "signature": s, "kind": k, "file": f, "line": l})).collect::<Vec<_>>(),
89            })
90        }
91        "graph.entities" | "architecture.components" => serde_json::to_value(crate::graph::components(&store)?)?,
92        "graph.flows" | "architecture.flows" => serde_json::to_value(crate::graph::flows(&store)?)?,
93        "graph.relationships" => {
94            let subject = input.get("subject").and_then(|v| v.as_str());
95            let predicate = input.get("predicate").and_then(|v| v.as_str());
96            let limit = input.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
97            serde_json::to_value(crate::graph::relationships(&store, subject, predicate, limit)?)?
98        }
99        "graph.search" => {
100            let q = input.get("query").and_then(|v| v.as_str()).unwrap_or("");
101            let limit = input.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
102            let mut entities = store.search_entities(q, limit)?;
103            if entities.is_empty() { entities = store.search_entities_like(q, limit)?; }
104            serde_json::to_value(entities)?
105        }
106        "graph.search_symbols" => {
107            let q = input.get("query").and_then(|v| v.as_str()).unwrap_or("");
108            let limit = input.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
109            let mut symbols = store.search_symbols(q, limit)?;
110            if symbols.is_empty() { symbols = store.search_symbols_like(q, limit)?; }
111            serde_json::json!({"symbols": symbols.iter().map(|(n, s, k, f, l)| serde_json::json!({"name": n, "signature": s, "kind": k, "file": f, "line": l})).collect::<Vec<_>>()})
112        }
113        "evidence.get" => {
114            let id = input.get("id").and_then(|v| v.as_str()).unwrap_or("");
115            serde_json::to_value(store.get_evidence(id)?)?
116        }
117        "evidence.list" => {
118            let path = input.get("path").and_then(|v| v.as_str());
119            let limit = input.get("limit").and_then(|v| v.as_u64()).unwrap_or(200) as usize;
120            let mut out = match path {
121                Some(p) => store.evidence_for_path(p)?,
122                None => store.all_evidence()?,
123            };
124            out.truncate(limit.max(1));
125            serde_json::to_value(out)?
126        }
127        "runtime.signatures" => {
128            let rows = store.trace_signatures()?;
129            serde_json::json!({"signatures": rows.iter().map(|(sig, count, lat, err, last)| serde_json::json!({"signature": sig, "count": count, "latency_ms": lat, "errors": err, "last_observed": last})).collect::<Vec<_>>()})
130        }
131        "integrations.describe" => {
132            let name = input.get("name").and_then(|v| v.as_str()).unwrap_or("");
133            let all = crate::integrations::list(root)?;
134            match all.into_iter().find(|(n, _)| n == name) {
135                Some((n, scope)) => serde_json::json!({"name": n, "scope": scope}),
136                None => serde_json::json!({"error": format!("unknown integration '{name}'")}),
137            }
138        }
139        "index.full" => serde_json::to_value(crate::index::full(root, &config)?)?,
140        "index.refresh" => {
141            let req: scc_api::IndexPathsRequest = serde_json::from_value(input)?;
142            serde_json::to_value(crate::index::refresh_paths(root, &config, &req.paths)?)?
143        }
144        "history.revisions" => serde_json::to_value(crate::history::revisions(&store)?)?,
145        "history.diff" => {
146            let req: scc_api::DiffRequest = serde_json::from_value(input)?;
147            serde_json::to_value(crate::history::diff(&store, req.from, req.to)?)?
148        }
149        "model.get" => serde_json::to_value(crate::exports::model_get(&store)?)?,
150        "context.external_docs" => {
151            let dep = input.get("dependency").and_then(|v| v.as_str()).unwrap_or("");
152            Value::String(crate::state::external_docs(root, dep)?)
153        }
154        "embeddings.build" => crate::state::embeddings_build(root)?,
155        "embeddings.get" => {
156            let id = input.get("id").and_then(|v| v.as_str()).unwrap_or("");
157            crate::state::embeddings_get(&store, id)?
158        }
159        "embeddings.status" => crate::state::embeddings_status(&store)?,
160        "export.system_ir" => {
161            let req: scc_api::ExportRequest = serde_json::from_value(input).unwrap_or(scc_api::ExportRequest { format: "system-ir.json".into() });
162            export_value(&store, root, &req.format)?
163        }
164        "export.system_ir_jsonl" => export_value(&store, root, "system-ir.jsonl")?,
165        "export.ccg" => export_value(&store, root, "ccg")?,
166        "export.flow_graphs" => export_value(&store, root, "flow-graphs.json")?,
167        "export.snap" => export_value(&store, root, "capsule.md")?,
168        "workspace.init" => {
169            let dir = crate::workspace::scc_dir(root);
170            std::fs::create_dir_all(&dir)?;
171            let cfg_path = crate::workspace::config_path(root);
172            if !cfg_path.exists() {
173                std::fs::write(&cfg_path, scc_indexer::Config::default_yaml())?;
174            }
175            crate::workspace::ensure_scc_ignored(root);
176            serde_json::json!({"initialized": dir.to_string_lossy(), "config": cfg_path.to_string_lossy()})
177        }
178        "workspace.state_path" => Value::String(crate::workspace::state_dir(root).to_string_lossy().into()),
179        "workspace.session" => serde_json::to_value(crate::workspace::open_session(&store, &config)?)?,
180        "workspace.session_check" => {
181            let session: crate::workspace::Session = serde_json::from_value(input.get("session").cloned().unwrap_or(serde_json::Value::Null))?;
182            serde_json::json!({"current": crate::workspace::session_is_current(&store, &config, &session)?})
183        }
184        "index.status" => {
185            let s = status_value(&store)?;
186            serde_json::to_value(&s)?
187        }
188        "resolution.run" => serde_json::to_value(crate::index::resolve_and_recompile(root)?)?,
189        "graph.recompile" => {
190            let r = crate::index::recompile(&store)?;
191            serde_json::json!({
192                "components": r.components,
193                "flows": r.flows,
194                "invariants": r.invariants,
195                "drift": r.drift,
196                "boundaries": r.boundaries,
197            })
198        },
199        "graph.entity.get" => {
200            let id = input.get("id").and_then(|v| v.as_str()).unwrap_or("");
201            let all = store.all_entities()?;
202            serde_json::to_value(all.into_iter().find(|e| e.id == id))?
203        }
204        "architecture.drift" => serde_json::to_value(crate::misc::drift(&store)?)?,
205        "architecture.cochange" => {
206            let min = input.get("min_commits").and_then(|v| v.as_u64()).unwrap_or(2) as u32;
207            let (pairs, enriched) = crate::misc::cochange(root, min)?;
208            serde_json::json!({"pairs": pairs, "enriched": enriched})
209        }
210        "integrity.invariants" | "architecture.invariants" => serde_json::to_value(crate::misc::check_invariants(&store)?)?,
211        "integrity.ci" => {
212            let max = input.get("max_severity").and_then(|v| v.as_str()).unwrap_or("medium");
213            let violations = crate::misc::check_invariants(&store)?;
214            let (ok, lines) = crate::misc::ci_check(&store, &violations, max)?;
215            serde_json::json!({"ok": ok, "lines": lines})
216        }
217        "integrations.list" => serde_json::to_value(crate::integrations::list(root)?)?,
218        "integrations.doctor" => {
219            let deep = input.get("deep").and_then(|v| v.as_bool()).unwrap_or(false);
220            let network = input.get("network").and_then(|v| v.as_bool()).unwrap_or(false);
221            serde_json::to_value(crate::integrations::doctor_report(&store, &config, root, deep, network)?)?
222        }
223        "lessons.add" => {
224            let text = input.get("text").and_then(|v| v.as_str()).unwrap_or("");
225            let (id, path) = crate::state::lessons_add(root, text)?;
226            serde_json::json!({"id": id, "path": path.to_string_lossy()})
227        }
228        "lessons.list" => {
229            let limit = input.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
230            serde_json::to_value(crate::state::lessons_list(root, limit)?)?
231        }
232        "beads.list" => serde_json::to_value(crate::state::beads(root, 20)?)?,
233        "setup.claude" | "setup.detected" | "setup.codex" | "setup.opencode" | "setup.hermes" | "setup.omp" | "setup.pi" => {
234            return Err(crate::EngineError::Other("setup operations are CLI-local file installation (harness dirs, home directory); not engine operations".into()));
235        }
236        "snapshot.save" => {
237            let req: scc_api::SnapshotSaveRequest = serde_json::from_value(input)?;
238            serde_json::to_value(crate::misc::snapshot_save(root, &req.task, req.budget)?)?
239        }
240        "snapshot.get" => {
241            let id = input.get("id").and_then(|v| v.as_str()).unwrap_or("");
242            serde_json::to_value(crate::snapshots::get(&store, id)?)?
243        }
244        "snapshot.diff" => {
245            let id = input.get("id").and_then(|v| v.as_str()).unwrap_or("");
246            serde_json::to_value(crate::snapshots::diff(&store, id)?)?
247        }
248        "checkpoint.save" => serde_json::to_value(crate::checkpoint::capture(root)?)?,
249        "checkpoint.load" => serde_json::to_value(crate::checkpoint::load(root)?)?,
250        "system.stitch" => {
251            let members: Vec<std::path::PathBuf> = input.get("members")
252                .and_then(|v| v.as_array())
253                .map(|a| a.iter().filter_map(|s| s.as_str().map(std::path::PathBuf::from)).collect())
254                .unwrap_or_default();
255            let (members, stitches) = crate::systems::stitch(&members)?;
256            serde_json::json!({"members": members.iter().map(|m| &m.repo_id).collect::<Vec<_>>(), "stitches": stitches})
257        }
258        "import.scip" | "import.ccg" | "import.gitnexus" | "import.tracelayer" | "import.beads" | "import.hindsight" | "import.cbm" => {
259            let format = operation.trim_start_matches("import.");
260            let file = input.get("file").and_then(|v| v.as_str()).unwrap_or("");
261            let r = crate::state::import_evidence(root, format, file)?;
262            serde_json::json!({
263                "symbols": r.symbols,
264                "calls": r.calls,
265                "imports": r.imports,
266                "errors": r.errors,
267            })
268        }
269        "export.diagram" => {
270            let format = input.get("format").and_then(|v| v.as_str()).unwrap_or("mermaid");
271            crate::exports::diagram(&store, format)?
272        }
273        "index.watch" => {
274            return Err(crate::EngineError::Other("index.watch is a streaming filesystem watch, not a request/response operation; run `scc watch`".into()));
275        }
276        "runtime.ingest" => {
277            let body = input.get("body").and_then(|v| v.as_str()).unwrap_or("");
278            crate::state::ingest_runtime(root, body)?;
279            serde_json::json!({"status": "accepted"})
280        }
281        "runtime.status" => serde_json::to_value(crate::state::runtime_edges(root)?)?,
282        "runtime.reconcile" => serde_json::to_value(crate::state::reconcile(root)?)?,
283        "ranking.important" => {
284            let limit = input.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
285            let task = input.get("task").and_then(|v| v.as_str()).map(|s| s.to_string());
286            let component = input.get("component").and_then(|v| v.as_str()).map(|s| s.to_string());
287            let (entries, tasked) = ctx.important(limit, component.as_deref(), task.as_deref())?;
288            serde_json::json!({"entries": entries, "tasked": tasked})
289        }
290        "ranking.symbols" => {
291            let req: scc_api::RankRequest = serde_json::from_value(input)?;
292            let ranker = engine.ranking();
293            let mut ap = crate::plugins::active(root, &config);
294            crate::plugins::order_extensions(&crate::plugins::collect_extensions(&ap))?;
295            let hooks = ranking_hooks_from_plugins(&mut ap, req.goal.as_deref().unwrap_or(""));
296            let out = ranker.symbols_with_hooks(&req, &hooks)?;
297            let mut v = serde_json::to_value(&out)?;
298            if !ap.diagnostics.is_empty() {
299                v["plugin_diagnostics"] = serde_json::to_value(&ap.diagnostics)?;
300            }
301            v
302        }
303        "ranking.candidates" => {
304            let goal = input.get("goal").and_then(|v| v.as_str()).unwrap_or("");
305            let limit = input.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
306            let mut ap = crate::plugins::active(root, &config);
307            crate::plugins::order_extensions(&crate::plugins::collect_extensions(&ap))?;
308            let hooks = ranking_hooks_from_plugins(&mut ap, goal);
309            let cands = engine.ranking().candidates_with(goal, limit, &hooks)?;
310            serde_json::json!({"candidates": cands.iter().map(|c| serde_json::json!({"id": c.id, "kind": c.kind, "name": c.name, "score": c.score, "reason": c.reason})).collect::<Vec<_>>()})
311        }
312        "ranking.pagerank.global" => {
313            // Raw stage introspection: no plugin hooks by contract.
314            let v = engine.ranking().pagerank_global()?;
315            serde_json::json!({"vector": v.iter().map(|(id, s)| serde_json::json!({"id": id, "score": s})).collect::<Vec<_>>()})
316        }
317        "ranking.pagerank.task" => {
318            // Raw stage introspection: no plugin hooks by contract.
319            let goal = input.get("goal").and_then(|v| v.as_str()).unwrap_or("");
320            let v = engine.ranking().pagerank_task(goal)?;
321            serde_json::json!({"vector": v.iter().map(|(id, s)| serde_json::json!({"id": id, "score": s})).collect::<Vec<_>>()})
322        }
323        "ranking.final_importance" => {
324            let f = |k: &str| input.get(k).and_then(|v| v.as_f64()).unwrap_or(0.0);
325            let has_task = input.get("has_task").and_then(|v| v.as_bool()).unwrap_or(true);
326            let s = scc_context::pagerank::final_importance(f("task_ppr"), f("global_ppr"), f("lexical"), f("semantic"), f("confidence"), f("criticality"), f("change_risk"), f("novelty"), has_task);
327            serde_json::json!({"score": s})
328        }
329        "ranking.edge_weight" => {
330            let predicate = input.get("predicate").and_then(|v| v.as_str()).unwrap_or("calls");
331            let confidence = input.get("confidence").and_then(|v| v.as_f64()).unwrap_or(1.0);
332            let total = input.get("total_symbols").and_then(|v| v.as_u64()).unwrap_or(1) as usize;
333            let indeg = input.get("target_in_degree").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
334            serde_json::json!({"weight": scc_context::pagerank::edge_weight(predicate, scc_core::Provenance::Extracted, confidence, total, indeg)})
335        }
336        "ranking.architectural_specificity" => {
337            let id = input.get("id").and_then(|v| v.as_str()).unwrap_or("");
338            let exported = input.get("exported").and_then(|v| v.as_bool()).unwrap_or(false);
339            serde_json::json!({"specificity": if exported { 1.15 } else { 1.0 }, "id": id})
340        }
341        "ranking.explain" => {
342            let id = input.get("id").and_then(|v| v.as_str()).unwrap_or("");
343            let goal = input.get("goal").and_then(|v| v.as_str()).unwrap_or("");
344            let req = scc_api::RankRequest { profile: None, goal: Some(goal.into()), limit: 1000, explain: true, include_features: true, include_intermediate: true };
345            let ranker = engine.ranking();
346            let mut ap = crate::plugins::active(root, &config);
347            crate::plugins::order_extensions(&crate::plugins::collect_extensions(&ap))?;
348            let hooks = ranking_hooks_from_plugins(&mut ap, goal);
349            let out = ranker.symbols_with_hooks(&req, &hooks)?;
350            match out.items.into_iter().find(|i| i.id == id) {
351                Some(item) => serde_json::to_value(&item)?,
352                None => serde_json::json!({"error": format!("symbol {id} not in ranking")}),
353            }
354        }
355        "ranking.global" | "ranking.task" | "ranking.entities" => {
356            let goal = input.get("goal").and_then(|v| v.as_str()).map(|s| s.to_string());
357            let limit = input.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
358            let req = scc_api::RankRequest { profile: None, goal, limit, explain: false, include_features: true, include_intermediate: false };
359            let mut ap = crate::plugins::active(root, &config);
360            crate::plugins::order_extensions(&crate::plugins::collect_extensions(&ap))?;
361            let hooks = ranking_hooks_from_plugins(&mut ap, req.goal.as_deref().unwrap_or(""));
362            serde_json::to_value(engine.ranking().symbols_with_hooks(&req, &hooks)?)?
363        }
364        "selection.mmr" => {
365            let req: scc_api::SelectionRequest = serde_json::from_value(input)?;
366            let ranked: Vec<(String, f64)> = req.ranked.iter().map(|e| (e.id.clone(), e.value)).collect();
367            let groups: Vec<Option<String>> = req.ranked.iter().map(|e| e.group.clone()).collect();
368            // Similarity hook chain (DoD 27): `similarity` extensions fire
369            // first; the same-group default runs last. Groups ride the
370            // request's `group` field (component/path supplied by caller).
371            let mut ap = crate::plugins::active(root, &config);
372            crate::plugins::order_extensions(&crate::plugins::collect_extensions(&ap))?;
373            let hooks = ranking_hooks_from_plugins(&mut ap, "");
374            let sims = std::sync::Arc::new(hooks.similarities);
375            let out = scc_context::selector::mmr_diversify(
376                &ranked,
377                |a: &str, b: &str| {
378                    let (ga, gb) = (group_of(&ranked, &groups, a), group_of(&ranked, &groups, b));
379                    crate::ranking::fold_similarity(&sims, a, b, ga, gb)
380                },
381                req.lambda.unwrap_or(0.5),
382                ranked.len(),
383            );
384            serde_json::json!({"selected": out})
385        }
386        "selection.quotas" => {
387            let req: scc_api::SelectionRequest = serde_json::from_value(input)?;
388            let rows: Vec<(String, String, f64, usize)> = req.ranked.iter().map(|e| (e.id.clone(), e.kind.clone(), e.value, e.token_cost)).collect();
389            let quotas: Vec<(String, f64)> = req.quotas.unwrap_or_default().iter().map(|q| (q.kind.clone(), q.fraction)).collect();
390            let budget: usize = req.ranked.iter().map(|e| e.token_cost).sum();
391            serde_json::json!({"selected": crate::ranking::apply_quotas(&rows, &quotas, budget)})
392        }
393        "selection.budget" => {
394            let req: scc_api::SelectionRequest = serde_json::from_value(input)?;
395            let items: Vec<scc_core::ContextItem> = req.ranked.iter().map(|e| scc_core::ContextItem { id: e.id.clone(), value: e.value, token_cost: e.token_cost, required: false, group: e.group.clone() }).collect();
396            let budget: usize = items.iter().map(|i| i.token_cost).sum();
397            serde_json::json!({"selected": crate::ranking::select_with_budget(&items, budget, budget)})
398        }
399        "plugins.list" => {
400            let ap = crate::plugins::active(root, &config);
401            serde_json::json!({"plugins": scc_plugin_host::discover(root).iter().map(|p| &p.manifest.id).collect::<Vec<_>>(), "lock": crate::plugins::lock_entries(&ap)})
402        }
403        "plugins.describe" => {
404            let ap = crate::plugins::active(root, &config);
405            let id = input.get("id").and_then(|v| v.as_str()).unwrap_or("");
406            match ap.plugins.iter().find(|p| p.manifest.id == id) {
407                Some(p) => serde_json::json!({"manifest": {"id": p.manifest.id, "name": p.manifest.name, "version": p.manifest.version, "api": p.manifest.api, "operations": p.manifest.operations, "timeout_ms": p.manifest.timeout_ms, "failure_policy": p.manifest.failure_policy, "deterministic": p.manifest.deterministic, "runtime": format!("{:?}", p.manifest.runtime).to_lowercase(), "extensions": p.manifest.extensions}, "lock": scc_plugin_host::lock_entry(p)}),
408                None => serde_json::json!({"error": format!("unknown plugin '{id}'")}),
409            }
410        }
411        "plugins.doctor" => {
412            let ap = crate::plugins::active(root, &config);
413            serde_json::json!({"plugins": ap.plugins.iter().map(|p| serde_json::json!({"id": p.manifest.id, "operations": p.manifest.operations})).collect::<Vec<_>>(), "diagnostics": ap.diagnostics})
414        }
415        "plugins.contribute" => {
416            let plugin = input.get("plugin").and_then(|v| v.as_str()).unwrap_or("");
417            let batch = input.get("batch").cloned().unwrap_or(serde_json::json!({}));
418            if plugin.is_empty() {
419                return Err(crate::EngineError::Other("plugins.contribute requires a `plugin` id".into()));
420            }
421            crate::plugins::commit_contribution(&store, plugin, &batch)?
422        }
423        "plugins.lock" => {
424            let ap = crate::plugins::active(root, &config);
425            let path = scc_plugin_host::write_lockfile(root, &ap.plugins).map_err(crate::EngineError::Other)?;
426            serde_json::json!({"ok": true, "path": path.to_string_lossy(), "plugins": crate::plugins::lock_entries(&ap)})
427        }
428        "plugins.check" => {
429            let ap = crate::plugins::active(root, &config);
430            match scc_plugin_host::check_lockfile(root, &ap.plugins) {
431                Ok(()) => serde_json::json!({"ok": true}),
432                Err(drift) => serde_json::json!({"ok": false, "drift": drift}),
433            }
434        }
435        "plugin_state.get" | "plugin_state.put" | "plugin_state.delete" | "plugin_state.scan" => {
436            let pid = input.get("plugin").and_then(|v| v.as_str()).unwrap_or("");
437            let ap = crate::plugins::active(root, &config);
438            let plug = ap.plugins.iter().find(|p| p.manifest.id == pid).ok_or_else(|| {
439                crate::EngineError::Other(format!("unknown plugin '{pid}' (not active)"))
440            })?;
441            match operation {
442                "plugin_state.get" => {
443                    let key = input.get("key").and_then(|v| v.as_str()).unwrap_or("");
444                    crate::state::plugin_state_get(&store, pid, &plug.grants, key)?
445                }
446                "plugin_state.put" => {
447                    let key = input.get("key").and_then(|v| v.as_str()).unwrap_or("");
448                    let value = input.get("value").map(|v| v.to_string()).unwrap_or_default();
449                    crate::state::plugin_state_put(&store, pid, &plug.grants, key, &value)?
450                }
451                "plugin_state.delete" => {
452                    let key = input.get("key").and_then(|v| v.as_str()).unwrap_or("");
453                    crate::state::plugin_state_delete(&store, pid, &plug.grants, key)?
454                }
455                _ => {
456                    let prefix = input.get("prefix").and_then(|v| v.as_str()).unwrap_or("");
457                    let limit = input.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
458                    crate::state::plugin_state_scan(&store, pid, &plug.grants, prefix, limit)?
459                }
460            }
461        }
462        "plugins.invoke" => {
463            let op = input.get("operation").and_then(|v| v.as_str()).unwrap_or(operation);
464            let inner = input.get("input").cloned().unwrap_or(serde_json::json!({}));
465            let mut ap = crate::plugins::active(root, &config);
466            crate::plugins::call_operation(&mut ap, op, inner)?
467        }
468        _ => {
469            // Custom plugin operations: no per-transport code needed (spec 31).
470            let mut ap = crate::plugins::active(root, &config);
471            match scc_plugin_host::provider_for(&ap.plugins, operation) {
472                Ok(_) => crate::plugins::call_operation(&mut ap, operation, input)?,
473                Err(_) => return Err(crate::EngineError::Other(format!("unknown operation '{operation}' (see operations.list)"))),
474            }
475        }
476    };
477    Ok(out)
478}
479
480// trace:exempt reason=internal-detail
481fn group_of<'a>(ranked: &[(String, f64)], groups: &'a [Option<String>], id: &str) -> Option<&'a str> {
482    ranked.iter().position(|(rid, _)| rid == id).and_then(|i| groups.get(i).and_then(|g| g.as_deref()))
483}
484
485// trace:exempt reason=internal-detail
486fn ranking_hooks_from_plugins(
487    ap: &mut crate::plugins::ActivePlugins,
488    goal: &str,
489) -> crate::ranking::RankHooks {
490    use std::sync::Arc;
491    let mut hooks = crate::ranking::RankHooks::default();
492    // Snapshot what each plugin provides (borrow ends before mutation).
493    // Extension registrations (§17) select hook wiring; legacy bare
494    // operation names (ranking.seed/feature/rerank) keep working.
495    // trace:exempt reason=internal-detail
496    struct PlugSpec { id: String, ops: Vec<String>, exts: Vec<(String, String)> }
497    let specs: Vec<PlugSpec> = ap.plugins.iter()
498        .map(|p| PlugSpec { id: p.manifest.id.clone(), ops: p.manifest.operations.clone(),
499            exts: p.manifest.extensions.iter().map(|e| (e.extension_type.clone(), e.id.clone())).collect() })
500        .collect();
501    for spec in specs {
502        let plug = match ap.plugins.iter().find(|p| p.manifest.id == spec.id).cloned() {
503            Some(p) => Arc::new(p),
504            None => continue,
505        };
506        let pid = spec.id.clone();
507        let wants = |t: &str, op: &str| {
508            spec.exts.iter().any(|(ty, _)| ty == t) || spec.ops.iter().any(|o| o == op)
509        };
510        if wants("candidate-provider", "ranking.candidates") {
511            let plug = Arc::clone(&plug);
512            let pid2 = pid.clone();
513            hooks.candidates.push(Box::new(move |goal: &str| {
514                let input = serde_json::json!({"goal": goal});
515                match scc_plugin_host::call(&plug, "ranking.candidates", input, None) {
516                    Ok(v) => v.get("candidates").and_then(|s| s.as_array()).map(|a| {
517                        a.iter().filter_map(|e| {
518                            let id = e.get("id").and_then(|x| x.as_str()).unwrap_or("").to_string();
519                            if id.is_empty() { return None; }
520                            Some(scc_context::rank::ScoredEntity {
521                                id,
522                                kind: e.get("kind").and_then(|x| x.as_str()).unwrap_or("symbol").into(),
523                                name: e.get("name").and_then(|x| x.as_str()).unwrap_or("").into(),
524                                score: e.get("score").and_then(|x| x.as_f64()).unwrap_or(0.0),
525                                reason: format!("plugin:{}", pid2),
526                            })
527                        }).collect()
528                    }).unwrap_or_default(),
529                    Err(_) => Vec::new(),
530                }
531            }));
532        }
533        if wants("seed-provider", "ranking.seed") {
534            let plug = Arc::clone(&plug);
535            let g = goal.to_string();
536            hooks.seed_providers.push(Box::new(move |_goal| {
537                let input = serde_json::json!({"goal": g});
538                match scc_plugin_host::call(&plug, "ranking.seed", input, None) {
539                    Ok(v) => v.get("seeds").and_then(|s| s.as_array()).map(|a| {
540                        a.iter().map(|e| scc_core::TaskSeed {
541                            kind: e.get("kind").and_then(|x| x.as_str()).unwrap_or("symbol").into(),
542                            id: e.get("id").and_then(|x| x.as_str()).unwrap_or("").into(),
543                            weight: e.get("weight").and_then(|x| x.as_f64()).unwrap_or(0.0),
544                        }).filter(|s| !s.id.is_empty()).collect()
545                    }).unwrap_or_default(),
546                    Err(_) => Vec::new(),
547                }
548            }));
549        }
550        if wants("rank-feature", "ranking.feature") {
551            let plug = Arc::clone(&plug);
552            hooks.features.push(Box::new(move |sym, goal| {
553                let input = serde_json::json!({"symbol": sym, "goal": goal});
554                match scc_plugin_host::call(&plug, "ranking.feature", input, None) {
555                    Ok(v) => crate::ranking::RankFeatureValue {
556                        name: format!("{pid}.feature"),
557                        score: v.get("score").and_then(|x| x.as_f64()).unwrap_or(0.0),
558                        weight: v.get("weight").and_then(|x| x.as_f64()).unwrap_or(0.0),
559                        reason: v.get("reason").and_then(|x| x.as_str()).unwrap_or("").into(),
560                    },
561                    Err(_) => crate::ranking::RankFeatureValue { name: format!("{pid}.feature"), score: 0.0, weight: 0.0, reason: String::new() },
562                }
563            }));
564        }
565        if wants("edge-weight", "ranking.edge_weight") {
566            let plug = Arc::clone(&plug);
567            hooks.edge_weights.push(std::sync::Arc::new(move |subject, predicate, object, base| {
568                let input = serde_json::json!({
569                    "subject": subject, "predicate": predicate,
570                    "object": object, "base": base,
571                });
572                match scc_plugin_host::call(&plug, "ranking.edge_weight", input, None) {
573                    Ok(v) => {
574                        let mode = v.get("mode").and_then(|x| x.as_str()).unwrap_or("").to_string();
575                        let value = v.get("value").and_then(|x| x.as_f64()).unwrap_or(0.0);
576                        match mode.as_str() {
577                            "add" | "multiply" | "replace" | "veto" => Some((mode, value)),
578                            _ => None,
579                        }
580                    }
581                    Err(_) => None,
582                }
583            }));
584        }
585        // Named blend profiles (spec 12 + DoD 25): a
586        // `blend-profile:<name>` extension registers profile `<name>` via
587        // the plugin's `ranking.profile` op returning weight overrides.
588        for eid in spec.exts.iter().filter(|(ty, _)| ty == "blend-profile").map(|(_, id)| id) {
589            let name = eid.strip_prefix("blend-profile:").unwrap_or(eid).to_string();
590            let plug = Arc::clone(&plug);
591            if let Ok(v) = scc_plugin_host::call(&plug, "ranking.profile", serde_json::json!({"profile": name}), None) {
592                let w = v.get("weights").cloned().unwrap_or(serde_json::Value::Null);
593                let mut bw = crate::ranking::BlendWeights::default();
594                let mut bad: Vec<String> = Vec::new();
595                if let Some(obj) = w.as_object() {
596                    for (k, val) in obj {
597                        let num = val.as_f64();
598                        match (k.as_str(), num) {
599                            ("task_ppr", Some(x)) => bw.task_ppr = Some(x),
600                            ("global_ppr", Some(x)) => bw.global_ppr = Some(x),
601                            ("lexical", Some(x)) => bw.lexical = Some(x),
602                            ("semantic", Some(x)) => bw.semantic = Some(x),
603                            ("confidence", Some(x)) => bw.confidence = Some(x),
604                            ("criticality", Some(x)) => bw.criticality = Some(x),
605                            ("change_risk", Some(x)) => bw.change_risk = Some(x),
606                            ("novelty", Some(x)) => bw.novelty = Some(x),
607                            _ => bad.push(k.clone()),
608                        }
609                    }
610                }
611                // Unknown feature keys fail loudly at registration, not
612                // silently ignored (would lie about the blend).
613                if !bad.is_empty() {
614                    ap.diagnostics.push(scc_plugin_host::PluginDiagnostic {
615                        plugin: spec.id.clone(), operation: "ranking.profile".into(),
616                        error: format!("unknown blend features for profile '{name}': {}", bad.join(", ")),
617                        action: "skipped".into(),
618                    });
619                    continue;
620                }
621                hooks.profiles.insert(name, bw);
622            }
623        }
624        if wants("similarity", "ranking.similarity") {
625            let plug = Arc::clone(&plug);
626            hooks.similarities.push(std::sync::Arc::new(move |a, b, ga, gb| {
627                let input = serde_json::json!({"a": a, "b": b, "group_a": ga, "group_b": gb});
628                match scc_plugin_host::call(&plug, "ranking.similarity", input, None) {
629                    Ok(v) => v.get("similarity").and_then(|x| x.as_f64()).unwrap_or(0.0),
630                    Err(_) => 0.0,
631                }
632            }));
633        }
634        if wants("reranker", "ranking.rerank") {
635            let plug = Arc::clone(&plug);
636            hooks.rerankers.push(Box::new(move |items, goal| {
637                let input = serde_json::json!({"goal": goal, "items": items.iter().map(|i: &scc_api::RankItem| i.id.clone()).collect::<Vec<_>>()});
638                if let Ok(v) = scc_plugin_host::call(&plug, "ranking.rerank", input, None) {
639                    if let Some(order) = v.get("order").and_then(|o| o.as_array()) {
640                        let pos: std::collections::HashMap<&str, usize> =
641                            order.iter().enumerate().filter_map(|(i, x)| x.as_str().map(|s| (s, i))).collect();
642                        items.sort_by_key(|i| (pos.get(i.id.as_str()).copied().unwrap_or(usize::MAX), i.id.clone()));
643                        for (k, it) in items.iter_mut().enumerate() { it.position = k + 1; }
644                    }
645                }
646            }));
647        }
648    }
649    hooks
650}
651
652// trace:exempt reason=internal-detail
653fn invoke_context(
654    ctx: &crate::SccContext,
655    store: &scc_store::Store,
656    config: &scc_indexer::Config,
657    operation: &str,
658    input: Value,
659) -> crate::Result<Value> {
660    Ok(match operation {
661        "context.component" => {
662            let req: scc_api::DetailRequest = serde_json::from_value(input)?;
663            serde_json::to_value(ctx.component(&req)?)?
664        }
665        "context.flow" => {
666            let req: scc_api::DetailRequest = serde_json::from_value(input)?;
667            serde_json::to_value(ctx.flow(&req)?)?
668        }
669        "context.impact" => {
670            let req: scc_api::ImpactRequest = serde_json::from_value(input)?;
671            serde_json::to_value(ctx.impact(&req)?)?
672        }
673        "context.verify" => {
674            let unbounded = input.get("unbounded").and_then(|v| v.as_bool()).unwrap_or(false);
675            serde_json::to_value(ctx.verify(unbounded)?)?
676        }
677        "context.structural" | "source.structural" => {
678            let req: scc_api::StructuralRequest = serde_json::from_value(input)?;
679            let goal = req.task.clone().unwrap_or_default();
680            let (scorer, _) = crate::inference::rankers(store, config, &goal);
681            let semantic: Option<&dyn scc_context::rank::SemanticScorer> =
682                scorer.as_ref().map(|s| s as &dyn scc_context::rank::SemanticScorer);
683            serde_json::json!({"text": ctx.structural(&req, &store.root, semantic)?})
684        }
685        "surface.build" | "ranking.important" => {
686            let req: scc_api::SurfaceRequest = serde_json::from_value(input)?;
687            let goal = req.task.clone().unwrap_or_default();
688            let (scorer, _) = crate::inference::rankers(store, config, &goal);
689            let semantic: Option<&dyn scc_context::rank::SemanticScorer> =
690                scorer.as_ref().map(|s| s as &dyn scc_context::rank::SemanticScorer);
691            let (result, text) = ctx.surface(&req, semantic)?;
692            serde_json::json!({ "result": result, "text": text })
693        }
694        _ => unreachable!("invoke routes only context ops here"),
695    })
696}
697
698// trace:exempt reason=internal-detail
699fn status_value(store: &scc_store::Store) -> crate::Result<Value> {
700    let repo = store.repository();
701    let stale = crate::workspace::stale_paths(store)?;
702    let (revision, indexed_at, branch) = match store.snapshot_status()? {
703        Some((snap, _)) => (snap.revision, Some(snap.indexed_at), snap.branch),
704        None => ("not-indexed".to_string(), None, None),
705    };
706    Ok(serde_json::json!({
707        "repository": repo.name,
708        "repository_id": repo.id,
709        "remote": repo.url,
710        "revision": revision,
711        "branch": branch,
712        "indexed_at": indexed_at,
713        "stats": store.stats()?,
714        "freshness": if stale.is_empty() { "CURRENT" } else { "STALE" },
715        "stale_files": stale.iter().take(10).collect::<Vec<_>>(),
716        "stale_count": stale.len(),
717    }))
718}
719
720// trace:exempt reason=internal-detail
721// trace:exempt reason=internal-detail
722// trace:exempt reason=internal-detail
723fn export_value(store: &scc_store::Store, root: &std::path::Path, format: &str) -> crate::Result<Value> {
724    let ir = crate::exports::system_ir(store)?;
725    match format {
726        "system-ir.json" | "system-ir.jsonl" | "ccg" | "flow-graphs.json" => {
727            Ok(match format {
728                "system-ir.jsonl" => Value::Array(crate::exports::jsonl(&ir)?.into_iter().map(Value::String).collect()),
729                "ccg" => crate::exports::ccg(&ir)?,
730                "flow-graphs.json" => serde_json::to_value(store.flow_graphs()?)?,
731                _ => serde_json::to_value(&ir)?,
732            })
733        }
734        "capsule.md" => Ok(Value::String(crate::exports::capsule(root)?)),
735        other => Err(crate::EngineError::Other(format!(
736            "unknown export format '{other}' (use system-ir.json, system-ir.jsonl, ccg, flow-graphs.json, capsule.md)"
737        ))),
738    }
739}