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            let model: bool = input.get("model").and_then(|v| v.as_bool()).unwrap_or(false);
33            // §74: `model: true` returns the structured SystemAtlas
34            // alongside the pack — one derivation, two views.
35            if model {
36                let scope = if full {
37                    scc_context::atlas::AtlasScope::Full
38                } else {
39                    scc_context::atlas::AtlasScope::Production
40                };
41                let pack = ctx.atlas(budget, full, unbounded)?;
42                serde_json::json!({"pack": pack, "model": ctx.atlas_model(scope)?})
43            } else {
44                serde_json::to_value(ctx.atlas(budget, full, unbounded)?)?
45            }
46        }
47        "context.startup" => {
48            let want_model = input.get("model").and_then(|v| v.as_bool()).unwrap_or(false);
49            let req: scc_api::StartupRequest = serde_json::from_value(input).unwrap_or(scc_api::StartupRequest { budget: None });
50            // Structured triple: text + budget + artifact. Transports that
51            // need a bare string take `.text`; nothing re-derives startup.
52            let (startup, text) = ctx.startup(&req)?;
53            let budget = scc_context::startup::allocate_startup_budget(&ctx.engine.ctx(), req.budget);
54            // Plugin startup sections (§124 item 29): verbatim markdown
55            // under provenance headers, appended by the engine so every
56            // transport delivers them. Skips are recorded inline (startup
57            // has no warnings channel); symbol visibility unaffected.
58            let (sections, notes) = crate::plugins::startup_sections(root, &config);
59            let mut text = text;
60            text.push_str(&sections);
61            for n in notes {
62                text.push_str(&format!("\n(startup section skipped: {n})\n"));
63            }
64            // §75: `model: true` adds the structured decomposition
65            // (atlas/skeleton/surface/coverage/omissions by key).
66            let mut out = serde_json::json!({"text": text, "budget": budget, "artifact": startup.artifact});
67            if want_model {
68                out["model"] = serde_json::to_value(ctx.startup_model(&req)?)?;
69            }
70            out
71        }
72        "context.task" => {
73            let req: scc_api::TaskContextRequest = serde_json::from_value(input)?;
74            let (scorer, reranker) = crate::inference::rankers(&store, &config, &req.goal);
75            let scorer_trait: Option<&dyn scc_context::rank::SemanticScorer> =
76                scorer.as_ref().map(|s| s as &dyn scc_context::rank::SemanticScorer);
77            let reranker_trait: Option<&dyn scc_context::rank::Reranker> =
78                reranker.as_ref().map(|r| r as &dyn scc_context::rank::Reranker);
79            serde_json::to_value(crate::task::build_task_context(&engine, &config, root, &req, scorer_trait, reranker_trait)?)?
80        }
81        "context.task_pack" => {
82            let req: scc_api::TaskContextRequest = serde_json::from_value(input)?;
83            let (scorer, reranker) = crate::inference::rankers(&store, &config, &req.goal);
84            let scorer_trait: Option<&dyn scc_context::rank::SemanticScorer> =
85                scorer.as_ref().map(|s| s as &dyn scc_context::rank::SemanticScorer);
86            let reranker_trait: Option<&dyn scc_context::rank::Reranker> =
87                reranker.as_ref().map(|r| r as &dyn scc_context::rank::Reranker);
88            serde_json::to_value(crate::task::build_enriched_task_pack(&engine, &config, root, &req, scorer_trait, reranker_trait)?)?
89        }
90        "context.task_delta" => {
91            let goal = input.get("goal").and_then(|v| v.as_str()).unwrap_or("");
92            let budget: usize = input.get("budget").and_then(|v| v.as_u64()).map(|v| v as usize).unwrap_or(scc_core::ContextBudget::default().task_delta);
93            // Spec §77: inspection without display must not suppress future
94            // deltas. record_visibility=false skips the ledger write;
95            // default true preserves current CLI behavior.
96            let record = input.get("record_visibility").and_then(|v| v.as_bool()).unwrap_or(true);
97            let (scorer, _) = crate::inference::rankers(&store, &config, goal);
98            let semantic: Option<&dyn scc_context::rank::SemanticScorer> =
99                scorer.as_ref().map(|s| s as &dyn scc_context::rank::SemanticScorer);
100            let (delta, ids) = ctx.task_delta(goal, budget, semantic)?;
101            if record {
102                ctx.record_task_delta_ids(&ids);
103            }
104            serde_json::json!({"delta": delta, "delta_ids": ids})
105        }
106        "context.subagent" => {
107            let goal = input.get("goal").and_then(|v| v.as_str()).unwrap_or("");
108            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();
109            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();
110            let budget = input.get("budget").and_then(|v| v.as_u64()).map(|b| b as usize);
111            serde_json::to_value(ctx.subagent(goal, &files, &symbols, budget)?)?
112        }
113        "context.compress" => {
114            // Pack-only compression ladder, no external summarizer, no delta,
115            // no ledger: same derivation the CLI uses with --cmd omitted.
116            let goal = input.get("goal").and_then(|v| v.as_str()).unwrap_or("");
117            let budget = input.get("budget").and_then(|v| v.as_u64()).map(|b| b as usize);
118            let (scorer, reranker) = crate::inference::rankers(&store, &config, goal);
119            let scorer_trait: Option<&dyn scc_context::rank::SemanticScorer> =
120                scorer.as_ref().map(|s| s as &dyn scc_context::rank::SemanticScorer);
121            let reranker_trait: Option<&dyn scc_context::rank::Reranker> =
122                reranker.as_ref().map(|r| r as &dyn scc_context::rank::Reranker);
123            let pack = crate::task::build_enriched_task_pack(&engine, &config, root, &scc_api::TaskContextRequest { goal: goal.into(), files: vec![], symbols: vec![], budget, hook: false, record_visibility: true }, scorer_trait, reranker_trait)?;
124            serde_json::to_value(pack)?
125        }
126        "context.component" | "context.flow" | "context.impact" | "context.verify" | "context.structural" | "source.structural" | "surface.build" | "surface.compile" | "surface.global" | "surface.task" | "surface.render" => {
127            invoke_context(&ctx, &store, &config, operation, input)?
128        }
129        "graph.query" => {
130            let req: scc_api::QueryRequest = serde_json::from_value(input)?;
131            let hit = crate::graph::query(&store, &req)?;
132            serde_json::json!({
133                "entities": hit.entities,
134                "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<_>>(),
135            })
136        }
137        "graph.traverse" => {
138            let req: scc_api::TraverseRequest = serde_json::from_value(input)?;
139            let cc = ctx.engine.ctx();
140            let (entities, relationships) = crate::graph::traverse(&cc, &req)?;
141            serde_json::json!({"entities": entities, "relationships": relationships, "trusted_only": req.trusted_only})
142        }
143        "graph.entities" | "architecture.components" => serde_json::to_value(crate::graph::components(&store)?)?,
144        "graph.flows" | "architecture.flows" => serde_json::to_value(crate::graph::flows(&store)?)?,
145        "graph.relationships" => {
146            let subject = input.get("subject").and_then(|v| v.as_str());
147            let predicate = input.get("predicate").and_then(|v| v.as_str());
148            let limit = input.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
149            serde_json::to_value(crate::graph::relationships(&store, subject, predicate, limit)?)?
150        }
151        "graph.search" => {
152            let q = input.get("query").and_then(|v| v.as_str()).unwrap_or("");
153            let limit = input.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
154            let mut entities = store.search_entities(q, limit)?;
155            if entities.is_empty() { entities = store.search_entities_like(q, limit)?; }
156            serde_json::to_value(entities)?
157        }
158        "graph.search_symbols" => {
159            let q = input.get("query").and_then(|v| v.as_str()).unwrap_or("");
160            let limit = input.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
161            let mut symbols = store.search_symbols(q, limit)?;
162            if symbols.is_empty() { symbols = store.search_symbols_like(q, limit)?; }
163            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<_>>()})
164        }
165        "evidence.get" => {
166            let id = input.get("id").and_then(|v| v.as_str()).unwrap_or("");
167            serde_json::to_value(store.get_evidence(id)?)?
168        }
169        "evidence.list" | "evidence.search" => {
170            let path = input.get("path").and_then(|v| v.as_str());
171            let limit = input.get("limit").and_then(|v| v.as_u64()).unwrap_or(200) as usize;
172            let mut out = match path {
173                Some(p) => store.evidence_for_path(p)?,
174                None => store.all_evidence()?,
175            };
176            out.truncate(limit.max(1));
177            serde_json::to_value(out)?
178        }
179        "runtime.signatures" => {
180            let rows = store.trace_signatures()?;
181            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<_>>()})
182        }
183        "integrations.describe" => {
184            let name = input.get("name").and_then(|v| v.as_str()).unwrap_or("");
185            let all = crate::integrations::list(root)?;
186            match all.into_iter().find(|(n, _)| n == name) {
187                Some((n, scope)) => serde_json::json!({"name": n, "scope": scope}),
188                None => serde_json::json!({"error": format!("unknown integration '{name}'")}),
189            }
190        }
191        "index.full" => serde_json::to_value(crate::index::full(root, &config)?)?,
192        "index.refresh" | "index.paths" => {
193            let req: scc_api::IndexPathsRequest = serde_json::from_value(input)?;
194            serde_json::to_value(crate::index::refresh_paths(root, &config, &req.paths)?)?
195        }
196        "history.revisions" | "history.list" => serde_json::to_value(crate::history::revisions(&store)?)?,
197        "history.diff" => {
198            let req: scc_api::DiffRequest = serde_json::from_value(input)?;
199            serde_json::to_value(crate::history::diff(&store, req.from, req.to)?)?
200        }
201        "model.get" | "model.components" | "model.flows" | "model.invariants" => serde_json::to_value(crate::exports::model_get(&store)?)?,
202        "context.external_docs" | "context.docs" => {
203            let dep = input.get("dependency").and_then(|v| v.as_str()).unwrap_or("");
204            Value::String(crate::state::external_docs(root, dep)?)
205        }
206        "embeddings.build" => crate::state::embeddings_build(root)?,
207        "embeddings.get" => {
208            let id = input.get("id").and_then(|v| v.as_str()).unwrap_or("");
209            crate::state::embeddings_get(&store, id)?
210        }
211        "embeddings.status" => crate::state::embeddings_status(&store)?,
212        "export.system_ir" => {
213            let req: scc_api::ExportRequest = serde_json::from_value(input).unwrap_or(scc_api::ExportRequest { format: "system-ir.json".into() });
214            export_value(&store, root, &req.format)?
215        }
216        "export.system_ir_jsonl" => export_value(&store, root, "system-ir.jsonl")?,
217        "export.ccg" => export_value(&store, root, "ccg")?,
218        "export.flow_graphs" => export_value(&store, root, "flow-graphs.json")?,
219        "export.snap" | "export.capsule" => export_value(&store, root, "capsule.md")?,
220        "operations.list" => {
221            serde_json::json!({"operations": crate::ops::ids(), "api_version": scc_api::API_VERSION})
222        }
223        "operations.capabilities" => {
224            serde_json::to_value(crate::ops::capabilities())?
225        }
226        "operations.schema" => {
227            let id = input.get("id").and_then(|v| v.as_str()).unwrap_or("");
228            serde_json::to_value(crate::ops::input_schema(id).ok_or_else(|| {
229                crate::EngineError::Other(format!("unknown operation '{id}' (see operations.list)"))
230            })?)?
231        }
232        "operations.describe" => {
233            let id = input.get("id").and_then(|v| v.as_str()).unwrap_or("");
234            match crate::ops::describe(id) {
235                Some(d) => serde_json::to_value(d)?,
236                None => return Err(crate::EngineError::Other(format!("unknown operation '{id}' (see operations.list)"))),
237            }
238        }
239        "workspace.init" => {
240            let dir = crate::workspace::scc_dir(root);
241            std::fs::create_dir_all(&dir)?;
242            let cfg_path = crate::workspace::config_path(root);
243            if !cfg_path.exists() {
244                std::fs::write(&cfg_path, scc_indexer::Config::default_yaml())?;
245            }
246            crate::workspace::ensure_scc_ignored(root);
247            serde_json::json!({"initialized": dir.to_string_lossy(), "config": cfg_path.to_string_lossy()})
248        }
249        "workspace.state_path" => Value::String(crate::workspace::state_dir(root).to_string_lossy().into()),
250        "workspace.scan" => {
251            let path = input.get("path").and_then(|v| v.as_str());
252            serde_json::to_value(crate::status::scan(root, &config, path)?)?
253        }
254        "workspace.session" => serde_json::to_value(crate::workspace::open_session(&store, &config)?)?,
255        "workspace.session_check" => {
256            let session: crate::workspace::Session = serde_json::from_value(input.get("session").cloned().unwrap_or(serde_json::Value::Null))?;
257            serde_json::json!({"current": crate::workspace::session_is_current(&store, &config, &session)?})
258        }
259        "index.status" => {
260            let s = status_value(&store)?;
261            serde_json::to_value(&s)?
262        }
263        "resolution.run" => serde_json::to_value(crate::index::resolve_and_recompile(root)?)?,
264        "graph.recompile" => {
265            let r = crate::index::recompile_with_signals(root, &config, &store)?;
266            serde_json::json!({
267                "components": r.components,
268                "flows": r.flows,
269                "invariants": r.invariants,
270                "drift": r.drift,
271                "boundaries": r.boundaries,
272            })
273        },
274        "graph.explain" => {
275            let subject = input.get("subject").and_then(|v| v.as_str()).unwrap_or("");
276            let predicate = input.get("predicate").and_then(|v| v.as_str()).unwrap_or("");
277            let object = input.get("object").and_then(|v| v.as_str()).unwrap_or("");
278            let cc = ctx.engine.ctx();
279            crate::graph::explain(&cc, subject, predicate, object)
280        }
281        "graph.entity.get" | "graph.entity" => {
282            let id = input.get("id").and_then(|v| v.as_str()).unwrap_or("");
283            let found = store.all_entities()?.into_iter().find(|e| e.id == id);
284            match found {
285                None => serde_json::json!({"entity": null, "trusted": false, "reason": "unknown id"}),
286                Some(e) => {
287                    let cc = ctx.engine.ctx();
288                    match cc.view.entity(&e.id) {
289                        Some(_) => serde_json::json!({"entity": e, "trusted": true, "reason": null}),
290                        None => serde_json::json!({"entity": e, "trusted": false, "reason": "hidden by TrustedGraphView (stale evidence or below trust floor)"}),
291                    }
292                }
293            }
294        }
295        "architecture.drift" | "model.drift" => serde_json::to_value(crate::misc::drift(&store)?)?,
296        "architecture.cochange" => {
297            let min = input.get("min_commits").and_then(|v| v.as_u64()).unwrap_or(2) as u32;
298            let (pairs, enriched) = crate::misc::cochange(root, min)?;
299            serde_json::json!({"pairs": pairs, "enriched": enriched})
300        }
301        "integrity.invariants" | "architecture.invariants" => serde_json::to_value(crate::misc::check_invariants(&store)?)?,
302        "integrity.ci" => {
303            let max = input.get("max_severity").and_then(|v| v.as_str()).unwrap_or("medium");
304            let violations = crate::misc::check_invariants(&store)?;
305            let (ok, lines) = crate::misc::ci_check(&store, &violations, max)?;
306            serde_json::json!({"ok": ok, "lines": lines})
307        }
308        "integrations.list" | "integration.list" => serde_json::to_value(crate::integrations::list(root)?)?,
309        "integrations.doctor" | "integration.doctor" => {
310            let deep = input.get("deep").and_then(|v| v.as_bool()).unwrap_or(false);
311            let network = input.get("network").and_then(|v| v.as_bool()).unwrap_or(false);
312            serde_json::to_value(crate::integrations::doctor_report(&store, &config, root, deep, network)?)?
313        }
314        "lessons.add" => {
315            let text = input.get("text").and_then(|v| v.as_str()).unwrap_or("");
316            let (id, path) = crate::state::lessons_add(root, text)?;
317            serde_json::json!({"id": id, "path": path.to_string_lossy()})
318        }
319        "lessons.list" => {
320            let limit = input.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
321            serde_json::to_value(crate::state::lessons_list(root, limit)?)?
322        }
323        "beads.list" | "beads.active" => serde_json::to_value(crate::state::beads(root, 20)?)?,
324        "viewer.panels" => {
325            // Plugin viewer panels (§124 item 32): structured data the CLI
326            // renders into viewer pages — same provenance pattern as the
327            // other contribution points, one seam every transport inherits.
328            let (panels, notes) = crate::plugins::viewer_panels(root, &config);
329            serde_json::json!({"panels": panels, "skipped": notes})
330        }
331        "viewer.snapshot" => {
332            return Err(crate::EngineError::Other("viewer.snapshot is CLI-local browser capture; not an engine operation".into()));
333        }
334        "setup.claude" | "setup.detected" | "setup.codex" | "setup.opencode" | "setup.hermes" | "setup.omp" | "setup.pi" => {
335            return Err(crate::EngineError::Other("setup operations are CLI-local file installation (harness dirs, home directory); not engine operations".into()));
336        }
337        "snapshot.save" => {
338            let req: scc_api::SnapshotSaveRequest = serde_json::from_value(input)?;
339            serde_json::to_value(crate::misc::snapshot_save(root, &req.task, req.budget)?)?
340        }
341        "snapshot.get" => {
342            let id = input.get("id").and_then(|v| v.as_str()).unwrap_or("");
343            serde_json::to_value(crate::snapshots::get(&store, id)?)?
344        }
345        "snapshot.diff" => {
346            let id = input.get("id").and_then(|v| v.as_str()).unwrap_or("");
347            serde_json::to_value(crate::snapshots::diff(&store, id)?)?
348        }
349        "checkpoint.save" => serde_json::to_value(crate::checkpoint::capture(root)?)?,
350        "checkpoint.load" => serde_json::to_value(crate::checkpoint::load(root)?)?,
351        "system.stitch" => {
352            let members: Vec<std::path::PathBuf> = input.get("members")
353                .and_then(|v| v.as_array())
354                .map(|a| a.iter().filter_map(|s| s.as_str().map(std::path::PathBuf::from)).collect())
355                .unwrap_or_default();
356            let (members, stitches) = crate::systems::stitch(&members)?;
357            serde_json::json!({"members": members.iter().map(|m| &m.repo_id).collect::<Vec<_>>(), "stitches": stitches})
358        }
359        "import.scip" | "import.ccg" | "import.gitnexus" | "import.tracelayer" | "import.beads" | "import.hindsight" | "import.cbm" => {
360            let format = operation.trim_start_matches("import.");
361            let file = input.get("file").and_then(|v| v.as_str()).unwrap_or("");
362            let r = crate::state::import_evidence(root, format, file)?;
363            serde_json::json!({
364                "symbols": r.symbols,
365                "calls": r.calls,
366                "imports": r.imports,
367                "errors": r.errors,
368            })
369        }
370        _ if operation.starts_with("import.") => {
371            // Plugin evidence providers (spec §31): `import.<plugin-id>`
372            // commits the plugin's batch through validate+commit.
373            let format = operation.trim_start_matches("import.");
374            let file = input.get("file").and_then(|v| v.as_str()).unwrap_or("");
375            let r = crate::state::import_evidence(root, format, file)?;
376            serde_json::json!({
377                "symbols": r.symbols,
378                "calls": r.calls,
379                "imports": r.imports,
380                "errors": r.errors,
381            })
382        }
383        "export.diagram" | "diagram.render" => {
384            let format = input.get("format").and_then(|v| v.as_str()).unwrap_or("mermaid");
385            crate::exports::diagram(&store, format)?
386        }
387        "index.watch" => {
388            return Err(crate::EngineError::Other("index.watch is a streaming filesystem watch, not a request/response operation; run `scc watch`".into()));
389        }
390        "runtime.ingest" => {
391            let body = input.get("body").and_then(|v| v.as_str()).unwrap_or("");
392            crate::state::ingest_runtime(root, body)?;
393            serde_json::json!({"status": "accepted"})
394        }
395        "runtime.status" => serde_json::to_value(crate::state::runtime_edges(root)?)?,
396        "runtime.reconcile" => serde_json::to_value(crate::state::reconcile(root)?)?,
397        "ranking.important" | "surface.important" => {
398            let limit = input.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
399            let task = input.get("task").and_then(|v| v.as_str()).map(|s| s.to_string());
400            let component = input.get("component").and_then(|v| v.as_str()).map(|s| s.to_string());
401            let (entries, tasked) = ctx.important(limit, component.as_deref(), task.as_deref())?;
402            serde_json::json!({"entries": entries, "tasked": tasked})
403        }
404        "ranking.symbols" | "surface.rank" => {
405            let req: scc_api::RankRequest = serde_json::from_value(input)?;
406            let ranker = engine.ranking();
407            let mut ap = crate::plugins::active(root, &config);
408            crate::plugins::order_extensions(&crate::plugins::collect_extensions(&ap))?;
409            let hooks = ranking_hooks_from_plugins(&mut ap, req.goal.as_deref().unwrap_or(""));
410            let out = ranker.symbols_with_hooks(&req, &hooks)?;
411            let mut v = serde_json::to_value(&out)?;
412            if !ap.diagnostics.is_empty() {
413                v["plugin_diagnostics"] = serde_json::to_value(&ap.diagnostics)?;
414            }
415            v
416        }
417        "ranking.candidates" => {
418            let goal = input.get("goal").and_then(|v| v.as_str()).unwrap_or("");
419            let limit = input.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
420            let mut ap = crate::plugins::active(root, &config);
421            crate::plugins::order_extensions(&crate::plugins::collect_extensions(&ap))?;
422            let hooks = ranking_hooks_from_plugins(&mut ap, goal);
423            let cands = engine.ranking().candidates_with(goal, limit, &hooks)?;
424            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<_>>()})
425        }
426        "ranking.project_symbols" => {
427            // Projection introspection (§123 intermediate): map a
428            // universe vector to per-symbol scores. `vector`: explicit
429            // (id, score) rows; or `source`: "global" / task `goal`.
430            let rows: Vec<(String, f64)> = match input.get("vector").and_then(|v| v.as_array()) {
431                Some(arr) => arr.iter().map(|r| (
432                    r.get("id").and_then(|x| x.as_str()).unwrap_or("").to_string(),
433                    r.get("score").and_then(|x| x.as_f64()).unwrap_or(0.0),
434                )).collect(),
435                None => match input.get("source").and_then(|v| v.as_str()) {
436                    Some("global") => engine.ranking().pagerank_global()?,
437                    _ => {
438                        let goal = input.get("goal").and_then(|v| v.as_str()).unwrap_or("");
439                        engine.ranking().pagerank_task(goal)?
440                    }
441                },
442            };
443            let out = engine.ranking().project_symbols(&rows)?;
444            serde_json::json!({"symbols": out.iter().map(|(id, s)| serde_json::json!({"id": id, "score": s})).collect::<Vec<_>>()})
445        }
446        "ranking.features" => {
447            // Feature-score introspection (§123.13): per-symbol core +
448            // plugin feature decomposition before the blend. Same hooks
449            // as ranking.symbols; no new math, projection only.
450            let req: scc_api::RankRequest = serde_json::from_value(input)?;
451            let ranker = engine.ranking();
452            let mut ap = crate::plugins::active(root, &config);
453            crate::plugins::order_extensions(&crate::plugins::collect_extensions(&ap))?;
454            let hooks = ranking_hooks_from_plugins(&mut ap, req.goal.as_deref().unwrap_or(""));
455            let out = ranker.symbols_with_hooks(&req, &hooks)?;
456            serde_json::json!({"features": out.items.iter().map(|i| serde_json::json!({
457                "id": i.id, "position": i.position,
458                "task_ppr": i.features.task_ppr, "global_ppr": i.features.global_ppr,
459                "lexical": i.features.lexical, "semantic": i.features.semantic,
460                "confidence": i.features.confidence, "criticality": i.features.criticality,
461                "change_risk": i.features.change_risk, "novelty": i.features.novelty,
462                "specificity": i.specificity, "plugin_features": i.plugin_features,
463            })).collect::<Vec<_>>()})
464        }
465        "ranking.reference_graph" => {
466            let edges = engine.ranking().reference_graph()?;
467            serde_json::to_value(&edges)?
468        }
469        "ranking.universe" => {
470            // Universe introspection: base view nodes merged with plugin
471            // rank-node providers (same merge symbols_with_hooks diffuses
472            // over) — the node table every vector is indexed by.
473            let goal = input.get("goal").and_then(|v| v.as_str()).unwrap_or("");
474            let mut ap = crate::plugins::active(root, &config);
475            crate::plugins::order_extensions(&crate::plugins::collect_extensions(&ap))?;
476            let hooks = ranking_hooks_from_plugins(&mut ap, goal);
477            let nodes = engine.ranking().universe_with(&hooks, goal)?;
478            serde_json::json!({"nodes": nodes.iter().map(|(id, k)| serde_json::json!({"id": id, "kind": k})).collect::<Vec<_>>()})
479        }
480        "ranking.edges" => {
481            let edges = engine.ranking().rank_edges()?;
482            serde_json::json!({"edges": edges.iter().map(|(s, p, o, w)| serde_json::json!({"subject": s, "predicate": p, "object": o, "weight": w})).collect::<Vec<_>>()})
483        }
484        "ranking.seeds" => {
485            // Task-seed introspection (§123.11): lexical seeds merged with
486            // plugin seed providers (weight sums by id). Read-only stage
487            // view — the same merge symbols_with_hooks consumes.
488            let goal = input.get("goal").and_then(|v| v.as_str()).unwrap_or("");
489            let mut ap = crate::plugins::active(root, &config);
490            crate::plugins::order_extensions(&crate::plugins::collect_extensions(&ap))?;
491            let hooks = ranking_hooks_from_plugins(&mut ap, goal);
492            let seeds = engine.ranking().seeds_with(goal, &hooks)?;
493            serde_json::json!({"seeds": seeds.iter().map(|x| serde_json::json!({"id": x.id, "kind": x.kind, "weight": x.weight})).collect::<Vec<_>>()})
494        }
495        "ranking.pagerank.global" | "ranking.global_vector" => {
496            // Raw stage introspection: no plugin hooks by contract.
497            let v = engine.ranking().pagerank_global()?;
498            serde_json::json!({"vector": v.iter().map(|(id, s)| serde_json::json!({"id": id, "score": s})).collect::<Vec<_>>()})
499        }
500        "ranking.pagerank.task" | "ranking.task_vector" => {
501            // Raw stage introspection: no plugin hooks by contract.
502            let goal = input.get("goal").and_then(|v| v.as_str()).unwrap_or("");
503            let v = engine.ranking().pagerank_task(goal)?;
504            serde_json::json!({"vector": v.iter().map(|(id, s)| serde_json::json!({"id": id, "score": s})).collect::<Vec<_>>()})
505        }
506        "ranking.final_importance" => {
507            let f = |k: &str| input.get(k).and_then(|v| v.as_f64()).unwrap_or(0.0);
508            let has_task = input.get("has_task").and_then(|v| v.as_bool()).unwrap_or(true);
509            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);
510            serde_json::json!({"score": s})
511        }
512        "ranking.score_entries" => {
513            let rows: Vec<crate::ranking::ScoreRow> = input.get("entries")
514                .and_then(|v| v.as_array()).map(|arr| arr.iter().map(|e| {
515                    let f = |k: &str| e.get(k).and_then(|v| v.as_f64()).unwrap_or(0.0);
516                    crate::ranking::ScoreRow {
517                        id: e.get("id").and_then(|v| v.as_str()).unwrap_or(""),
518                        task_ppr: f("task_ppr"), global_ppr: f("global_ppr"),
519                        lexical: f("lexical"), semantic: f("semantic"),
520                        confidence: f("confidence"), criticality: f("criticality"),
521                        change_risk: f("change_risk"), novelty: f("novelty"),
522                        has_task: e.get("has_task").and_then(|v| v.as_bool()).unwrap_or(true),
523                    }
524                }).collect()).unwrap_or_default();
525            let out = crate::ranking::score_entries(&rows);
526            serde_json::json!({"scores": out.iter().map(|(id, s)| serde_json::json!({"id": id, "score": s})).collect::<Vec<_>>()})
527        }
528        "ranking.edge_weight" => {
529            let predicate = input.get("predicate").and_then(|v| v.as_str()).unwrap_or("calls");
530            let confidence = input.get("confidence").and_then(|v| v.as_f64()).unwrap_or(1.0);
531            let total = input.get("total_symbols").and_then(|v| v.as_u64()).unwrap_or(1) as usize;
532            let indeg = input.get("target_in_degree").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
533            // Provenance-aware weight math (§49 EdgeWeightProvider): the
534            // caller names the provenance; the op decomposes the weight
535            // into its predicate/provenance/confidence/rarity factors so a
536            // plugin can reason about each factor before contributing.
537            let prov = match input.get("provenance").and_then(|v| v.as_str()).unwrap_or("EXTRACTED") {
538                "RESOLVED" => scc_core::Provenance::Resolved,
539                "OBSERVED" => scc_core::Provenance::Observed,
540                "DECLARED" => scc_core::Provenance::Declared,
541                "INFERRED" => scc_core::Provenance::Inferred,
542                "STALE" => scc_core::Provenance::Stale,
543                _ => scc_core::Provenance::Extracted,
544            };
545            serde_json::json!({
546                "weight": scc_context::pagerank::edge_weight(predicate, prov, confidence, total, indeg),
547                "factors": {
548                    "predicate": scc_context::pagerank::predicate_weight(predicate, prov),
549                    "provenance": scc_context::pagerank::provenance_weight(prov),
550                    "confidence": confidence.clamp(0.0, 1.0),
551                    "rarity": scc_context::pagerank::rarity(total, indeg),
552                },
553            })
554        }
555        "ranking.architectural_specificity" => {
556            let id = input.get("id").and_then(|v| v.as_str()).unwrap_or("");
557            let exported = input.get("exported").and_then(|v| v.as_bool()).unwrap_or(false);
558            serde_json::json!({"specificity": if exported { 1.15 } else { 1.0 }, "id": id})
559        }
560        "ranking.entry" => {
561            let id = input.get("id").and_then(|v| v.as_str()).unwrap_or("");
562            match ctx.surface_entry(id)? {
563                Some(e) => serde_json::to_value(&e)?,
564                None => serde_json::json!({"error": format!("entry {id} not in surface map")}),
565            }
566        }
567        "ranking.trace" => {
568            // Full ranking trace (§19): items + the seed/required
569            // inputs the blend consumed. Same computation as
570            // ranking.symbols; the envelope is the audit path.
571            let req: scc_api::RankRequest = serde_json::from_value(input)?;
572            let goal = req.goal.clone().unwrap_or_default();
573            let mut ap = crate::plugins::active(root, &config);
574            crate::plugins::order_extensions(&crate::plugins::collect_extensions(&ap))?;
575            let hooks = ranking_hooks_from_plugins(&mut ap, &goal);
576            let (out, seeds, required) = engine.ranking().trace_with_hooks(&req, &hooks)?;
577            let mut v = serde_json::to_value(&out)?;
578            v["seeds"] = serde_json::json!(seeds);
579            v["required"] = serde_json::json!(required);
580            v
581        }
582        "ranking.explain" | "surface.explain" => {
583            let id = input.get("id").and_then(|v| v.as_str()).unwrap_or("");
584            let goal = input.get("goal").and_then(|v| v.as_str()).unwrap_or("");
585            let req = scc_api::RankRequest { profile: None, goal: Some(goal.into()), limit: 1000, explain: true, include_features: true, include_intermediate: true };
586            let ranker = engine.ranking();
587            let mut ap = crate::plugins::active(root, &config);
588            crate::plugins::order_extensions(&crate::plugins::collect_extensions(&ap))?;
589            let hooks = ranking_hooks_from_plugins(&mut ap, goal);
590            let out = ranker.symbols_with_hooks(&req, &hooks)?;
591            match out.items.into_iter().find(|i| i.id == id) {
592                Some(item) => serde_json::to_value(&item)?,
593                None => serde_json::json!({"error": format!("symbol {id} not in ranking")}),
594            }
595        }
596        "ranking.global" | "ranking.task" | "ranking.entities" => {
597            let goal = input.get("goal").and_then(|v| v.as_str()).map(|s| s.to_string());
598            let limit = input.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
599            let req = scc_api::RankRequest { profile: None, goal, limit, explain: false, include_features: true, include_intermediate: false };
600            let mut ap = crate::plugins::active(root, &config);
601            crate::plugins::order_extensions(&crate::plugins::collect_extensions(&ap))?;
602            let hooks = ranking_hooks_from_plugins(&mut ap, req.goal.as_deref().unwrap_or(""));
603            serde_json::to_value(engine.ranking().symbols_with_hooks(&req, &hooks)?)?
604        }
605        "selection.mmr" => {
606            let req: scc_api::SelectionRequest = serde_json::from_value(input)?;
607            let ranked: Vec<(String, f64)> = req.ranked.iter().map(|e| (e.id.clone(), e.value)).collect();
608            let groups: Vec<Option<String>> = req.ranked.iter().map(|e| e.group.clone()).collect();
609            // Similarity hook chain (DoD 27): `similarity` extensions fire
610            // first; the same-group default runs last. Groups ride the
611            // request's `group` field (component/path supplied by caller).
612            let mut ap = crate::plugins::active(root, &config);
613            crate::plugins::order_extensions(&crate::plugins::collect_extensions(&ap))?;
614            // Diversity-policy replacement (§124 item 24): a single
615            // declarer replaces MMR wholesale (verbatim answer).
616            if let Some(sel) = crate::plugins::diversity_selection(&ap, &req)? {
617                let mut out = serde_json::json!({"selected": sel});
618                if !ap.diagnostics.is_empty() { out["plugin_diagnostics"] = serde_json::to_value(&ap.diagnostics)?; }
619                return Ok(out);
620            }
621            let hooks = ranking_hooks_from_plugins(&mut ap, "");
622            let sims = std::sync::Arc::new(hooks.similarities);
623            let out = scc_context::selector::mmr_diversify(
624                &ranked,
625                |a: &str, b: &str| {
626                    let (ga, gb) = (crate::ranking::group_of2(&ranked, &groups, a), crate::ranking::group_of2(&ranked, &groups, b));
627                    crate::ranking::fold_similarity(&sims, a, b, ga, gb)
628                },
629                req.lambda.unwrap_or(0.5),
630                ranked.len(),
631            );
632            serde_json::json!({"selected": out})
633        }
634        "selection.quotas" => {
635            let req: scc_api::SelectionRequest = serde_json::from_value(input)?;
636            let rows: Vec<(String, String, f64, usize)> = req.ranked.iter().map(|e| (e.id.clone(), e.kind.clone(), e.value, e.token_cost)).collect();
637            let mut quotas: Vec<(String, f64)> = req.quotas.clone().unwrap_or_default().iter().map(|q| (q.kind.clone(), q.fraction)).collect();
638            // Quota-policy extensions (§124 item 25): declaring plugins
639            // override per-kind fractions (plugin wins per kind). Merged
640            // before the single apply_quotas call — one math path.
641            let mut ap = crate::plugins::active(root, &config);
642            let (overrides, notes) = crate::plugins::quota_overrides(&ap, &req);
643            for (kind, frac) in &overrides {
644                if let Some(slot) = quotas.iter_mut().find(|(k, _)| k == kind) { slot.1 = *frac; }
645                else { quotas.push((kind.clone(), *frac)); }
646            }
647            if !notes.is_empty() { ap.diagnostics.extend(notes); }
648            let budget: usize = req.ranked.iter().map(|e| e.token_cost).sum();
649            let mut out = serde_json::json!({"selected": crate::ranking::apply_quotas(&rows, &quotas, budget)});
650            if !overrides.is_empty() {
651                out["quota_overrides"] = serde_json::json!(overrides.iter().map(|(k, f)| serde_json::json!({"kind": k, "fraction": f})).collect::<Vec<_>>());
652            }
653            if !ap.diagnostics.is_empty() { out["plugin_diagnostics"] = serde_json::to_value(&ap.diagnostics)?; }
654            out
655        }
656        "selection.required" => {
657            // Required-coverage set (§123: never-omit entries): engine
658            // required_ids + plugin coverage providers, unioned. Same
659            // inputs symbols_with_hooks blends criticality from.
660            let goal = input.get("goal").and_then(|v| v.as_str()).unwrap_or("");
661            let mut ap = crate::plugins::active(root, &config);
662            crate::plugins::order_extensions(&crate::plugins::collect_extensions(&ap))?;
663            let hooks = ranking_hooks_from_plugins(&mut ap, goal);
664            let base: usize = engine.ranking().required_with("", &crate::ranking::RankHooks::default())?.len();
665            let mut ids: Vec<String> = engine.ranking().required_with(goal, &hooks)?.into_iter().collect();
666            ids.sort();
667            let contributed = ids.len().saturating_sub(base);
668            serde_json::json!({"required": ids, "plugin_contributed": contributed})
669        }
670        "selection.preview" => {
671            // Selection-effects introspection (§123.14): per-stage
672            // survivors through the DEFAULT chain (MMR → quotas →
673            // budget) over caller-supplied rows. Default math only, no
674            // plugin policies — shows WHERE each id drops out.
675            let req: scc_api::SelectionRequest = serde_json::from_value(input)?;
676            let ranked: Vec<(String, f64)> = req.ranked.iter().map(|e| (e.id.clone(), e.value)).collect();
677            let groups: Vec<Option<String>> = req.ranked.iter().map(|e| e.group.clone()).collect();
678            let sim = |a: &str, b: &str| {
679                let (ga, gb) = (crate::ranking::group_of2(&ranked, &groups, a), crate::ranking::group_of2(&ranked, &groups, b));
680                crate::ranking::default_similarity(ga, gb)
681            };
682            let after_mmr = scc_context::selector::mmr_diversify(
683                &ranked, sim, req.lambda.unwrap_or(0.5), ranked.len());
684            let rows: Vec<(String, String, f64, usize)> = req.ranked.iter()
685                .map(|e| (e.id.clone(), e.kind.clone(), e.value, e.token_cost)).collect();
686            let quotas: Vec<(String, f64)> = req.quotas.unwrap_or_default().iter()
687                .map(|q| (q.kind.clone(), q.fraction)).collect();
688            let budget: usize = req.ranked.iter().map(|e| e.token_cost).sum();
689            let after_quotas = crate::ranking::apply_quotas(&rows, &quotas, budget);
690            let qset: std::collections::BTreeSet<&str> =
691                after_quotas.iter().map(|x| x.as_str()).collect();
692            let items: Vec<scc_core::ContextItem> = req.ranked.iter()
693                .filter(|e| qset.contains(e.id.as_str()))
694                .map(|e| scc_core::ContextItem { id: e.id.clone(), value: e.value,
695                    token_cost: e.token_cost, required: false, group: e.group.clone() }).collect();
696            let keep = crate::ranking::select_with_budget(&items, budget, budget);
697            let after_budget: Vec<String> = keep.into_iter().map(|i| items[i].id.clone()).collect();
698            serde_json::json!({
699                "after_mmr": after_mmr, "after_quotas": after_quotas,
700                "after_budget": after_budget,
701            })
702        }
703        "selection.budget" | "selection.optimize" | "surface.select" => {
704            let req: scc_api::SelectionRequest = serde_json::from_value(input)?;
705            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();
706            let budget: usize = items.iter().map(|i| i.token_cost).sum();
707            // Budget-optimizer extension (§124 item 27): at most one
708            // declarer; the plugin returns the full selected id list.
709            let ap = crate::plugins::active(root, &config);
710            match crate::plugins::budget_selection(&ap, &req, &items, budget)? {
711                Some(sel) => {
712                    let mut out = serde_json::json!({"selected": sel});
713                    if !ap.diagnostics.is_empty() { out["plugin_diagnostics"] = serde_json::to_value(&ap.diagnostics)?; }
714                    out
715                }
716                None => {
717                    let keep = crate::ranking::select_with_budget(&items, budget, budget);
718                    let ids: Vec<&str> = keep.into_iter().map(|i| items[i].id.as_str()).collect();
719                    serde_json::json!({"selected": ids})
720                }
721            }
722        }
723        "plugins.list" => {
724            let ap = crate::plugins::active(root, &config);
725            serde_json::json!({"plugins": scc_plugin_host::discover(root).iter().map(|p| &p.manifest.id).collect::<Vec<_>>(), "lock": crate::plugins::lock_entries(&ap)})
726        }
727        "plugins.describe" | "plugins.inspect" => {
728            let ap = crate::plugins::active(root, &config);
729            let id = input.get("id").and_then(|v| v.as_str()).unwrap_or("");
730            match ap.plugins.iter().find(|p| p.manifest.id == id) {
731                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)}),
732                None => serde_json::json!({"error": format!("unknown plugin '{id}'")}),
733            }
734        }
735        "plugins.doctor" => {
736            let ap = crate::plugins::active(root, &config);
737            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})
738        }
739        "plugins.contribute" => {
740            let plugin = input.get("plugin").and_then(|v| v.as_str()).unwrap_or("");
741            let batch = input.get("batch").cloned().unwrap_or(serde_json::json!({}));
742            if plugin.is_empty() {
743                return Err(crate::EngineError::Other("plugins.contribute requires a `plugin` id".into()));
744            }
745            crate::plugins::commit_contribution(&store, plugin, &batch)?
746        }
747        "plugins.promote" => {
748            // Sidecar promotion (§124 item 36): selected sidecar findings
749            // enter the canonical graph through the normal validate+commit
750            // path. Same provenance/conflict rules as every contribution.
751            let plugin = input.get("plugin").and_then(|v| v.as_str()).unwrap_or("");
752            if plugin.is_empty() {
753                return Err(crate::EngineError::Other("plugins.promote requires a `plugin` id".into()));
754            }
755            let assertions = input.get("assertions").cloned().unwrap_or(serde_json::json!([]));
756            crate::plugins::promote_sidecar(&store, plugin, &assertions)?
757        }
758        "plugins.lock" => {
759            let ap = crate::plugins::active(root, &config);
760            let path = scc_plugin_host::write_lockfile(root, &ap.plugins).map_err(crate::EngineError::Other)?;
761            serde_json::json!({"ok": true, "path": path.to_string_lossy(), "plugins": crate::plugins::lock_entries(&ap)})
762        }
763        "plugins.graph" => {
764            let ap = crate::plugins::active(root, &config);
765            let exts = crate::plugins::collect_extensions(&ap);
766            let order = crate::plugins::order_extensions(&exts)?;
767            // Group by type in deterministic key order, listing
768            // `type:id` in execution order with priority.
769            let mut by_type: std::collections::BTreeMap<String, Vec<serde_json::Value>> = std::collections::BTreeMap::new();
770            for i in order {
771                let e = &exts[i];
772                by_type.entry(e.extension_type.clone()).or_default().push(
773                    serde_json::json!({"id": e.id, "key": e.key(), "priority": e.priority}),
774                );
775            }
776            serde_json::json!({"groups": by_type})
777        }
778        "plugins.check" => {
779            let ap = crate::plugins::active(root, &config);
780            match scc_plugin_host::check_lockfile(root, &ap.plugins) {
781                Ok(()) => serde_json::json!({"ok": true}),
782                Err(drift) => serde_json::json!({"ok": false, "drift": drift}),
783            }
784        }
785        "plugins.enable" | "plugins.disable" => {
786            // Project allow-list (§29 enable/disable): mutate
787            // `plugins.enabled` in .scc/config.yaml in place, preserving
788            // every other key (raw YAML edit, not a struct round-trip —
789            // unknown keys must survive). Empty list means "all
790            // discovered run"; enable adds the id, disable removes it.
791            // Undiscovered ids fail loudly — a typo must not silently
792            // narrow the set.
793            let id = input.get("id").and_then(|v| v.as_str()).unwrap_or("");
794            if id.is_empty() {
795                return Err(crate::EngineError::Other(format!("{operation} requires an `id`")));
796            }
797            let known: Vec<String> = scc_plugin_host::discover(root).into_iter().map(|p| p.manifest.id).collect();
798            if !known.iter().any(|k| k == id) {
799                return Err(crate::EngineError::Other(format!("unknown plugin '{id}' (discovered: {})", known.join(", "))));
800            }
801            let path = crate::workspace::config_path(root);
802            let mut doc: serde_yaml::Value = if path.exists() {
803                let text = std::fs::read_to_string(&path).map_err(|e| crate::EngineError::Other(e.to_string()))?;
804                serde_yaml::from_str(&text).map_err(|e| crate::EngineError::Other(e.to_string()))?
805            } else {
806                serde_yaml::from_str(&scc_indexer::Config::default_yaml()).map_err(|e| crate::EngineError::Other(e.to_string()))?
807            };
808            if !doc.get("plugins").is_some() {
809                if let Some(obj) = doc.as_mapping_mut() {
810                    obj.insert(serde_yaml::Value::String("plugins".into()), serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
811                }
812            }
813            let mut cur: Vec<String> = doc.get("plugins").and_then(|p| p.get("enabled")).and_then(|v| serde_yaml::from_value(v.clone()).ok()).unwrap_or_default();
814            if operation == "plugins.enable" {
815                if !cur.iter().any(|x| x == id) { cur.push(id.to_string()); }
816            } else {
817                cur.retain(|x| x != id);
818            }
819            if let Some(plugins) = doc.get_mut("plugins").and_then(|p| p.as_mapping_mut()) {
820                plugins.insert(serde_yaml::Value::String("enabled".into()), serde_yaml::to_value(&cur).map_err(|e| crate::EngineError::Other(e.to_string()))?);
821            }
822            if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| crate::EngineError::Other(e.to_string()))?; }
823            let text = serde_yaml::to_string(&doc).map_err(|e| crate::EngineError::Other(e.to_string()))?;
824            std::fs::write(&path, text).map_err(|e| crate::EngineError::Other(e.to_string()))?;
825            let cfg = crate::workspace::load_config(root)?;
826            let ap = crate::plugins::active(root, &cfg);
827            serde_json::json!({"ok": true, "enabled": cfg.plugins.enabled, "active": ap.plugins.iter().map(|p| &p.manifest.id).collect::<Vec<_>>()})
828        }
829        "plugin_state.get" | "plugin_state.put" | "plugin_state.delete" | "plugin_state.scan" => {
830            let pid = input.get("plugin").and_then(|v| v.as_str()).unwrap_or("");
831            let ap = crate::plugins::active(root, &config);
832            let plug = ap.plugins.iter().find(|p| p.manifest.id == pid).ok_or_else(|| {
833                crate::EngineError::Other(format!("unknown plugin '{pid}' (not active)"))
834            })?;
835            match operation {
836                "plugin_state.get" => {
837                    let key = input.get("key").and_then(|v| v.as_str()).unwrap_or("");
838                    crate::state::plugin_state_get(&store, pid, &plug.grants, key)?
839                }
840                "plugin_state.put" => {
841                    let key = input.get("key").and_then(|v| v.as_str()).unwrap_or("");
842                    let value = input.get("value").map(|v| v.to_string()).unwrap_or_default();
843                    crate::state::plugin_state_put(&store, pid, &plug.grants, key, &value)?
844                }
845                "plugin_state.delete" => {
846                    let key = input.get("key").and_then(|v| v.as_str()).unwrap_or("");
847                    crate::state::plugin_state_delete(&store, pid, &plug.grants, key)?
848                }
849                _ => {
850                    let prefix = input.get("prefix").and_then(|v| v.as_str()).unwrap_or("");
851                    let limit = input.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
852                    crate::state::plugin_state_scan(&store, pid, &plug.grants, prefix, limit)?
853                }
854            }
855        }
856        "sidecar.put" | "sidecar.get" | "sidecar.scan" => {
857            // Raw sidecar storage (§124 item 35): (plugin, graph, key)
858            // namespaced analyzer facts. Grant-gated like plugin state;
859            // never consumed by ranking/context — promotion only.
860            let pid = input.get("plugin").and_then(|v| v.as_str()).unwrap_or("");
861            let ap = crate::plugins::active(root, &config);
862            let plug = ap.plugins.iter().find(|p| p.manifest.id == pid).ok_or_else(|| {
863                crate::EngineError::Other(format!("unknown plugin '{pid}' (not active)"))
864            })?;
865            match operation {
866                "sidecar.put" => {
867                    let graph = input.get("graph").and_then(|v| v.as_str()).unwrap_or("default");
868                    let key = input.get("key").and_then(|v| v.as_str()).unwrap_or("");
869                    let value = input.get("value").map(|v| v.to_string()).unwrap_or_default();
870                    crate::state::sidecar_put(&store, pid, &plug.grants, graph, key, &value)?
871                }
872                "sidecar.get" => {
873                    let graph = input.get("graph").and_then(|v| v.as_str()).unwrap_or("default");
874                    let key = input.get("key").and_then(|v| v.as_str()).unwrap_or("");
875                    crate::state::sidecar_get(&store, pid, &plug.grants, graph, key)?
876                }
877                _ => {
878                    let graph = input.get("graph").and_then(|v| v.as_str()).unwrap_or("default");
879                    let prefix = input.get("prefix").and_then(|v| v.as_str()).unwrap_or("");
880                    let limit = input.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
881                    crate::state::sidecar_scan(&store, pid, &plug.grants, graph, prefix, limit)?
882                }
883            }
884        }
885        "plugins.invoke" => {
886            let op = input.get("operation").and_then(|v| v.as_str()).unwrap_or(operation);
887            let inner = input.get("input").cloned().unwrap_or(serde_json::json!({}));
888            let mut ap = crate::plugins::active(root, &config);
889            crate::plugins::call_operation(&mut ap, op, inner)?
890        }
891        _ => {
892            // Custom plugin operations: no per-transport code needed (spec 31).
893            let mut ap = crate::plugins::active(root, &config);
894            match scc_plugin_host::provider_for(&ap.plugins, operation) {
895                Ok(_) => crate::plugins::call_operation(&mut ap, operation, input)?,
896                Err(_) => return Err(crate::EngineError::Other(format!("unknown operation '{operation}' (see operations.list)"))),
897            }
898        }
899    };
900    Ok(out)
901}
902
903// trace:exempt reason=internal-detail
904fn ranking_hooks_from_plugins(
905    ap: &mut crate::plugins::ActivePlugins,
906    goal: &str,
907) -> crate::ranking::RankHooks {
908    use std::sync::Arc;
909    let mut hooks = crate::ranking::RankHooks::default();
910    // Snapshot what each plugin provides (borrow ends before mutation).
911    // Extension registrations (§17) select hook wiring; legacy bare
912    // operation names (ranking.seed/feature/rerank) keep working.
913    // trace:exempt reason=internal-detail
914    struct PlugSpec { id: String, ops: Vec<String>, exts: Vec<(String, String)> }
915    let specs: Vec<PlugSpec> = ap.plugins.iter()
916        .map(|p| PlugSpec { id: p.manifest.id.clone(), ops: p.manifest.operations.clone(),
917            exts: p.manifest.extensions.iter().map(|e| (e.extension_type.clone(), e.id.clone())).collect() })
918        .collect();
919    for spec in specs {
920        let plug = match ap.plugins.iter().find(|p| p.manifest.id == spec.id).cloned() {
921            Some(p) => Arc::new(p),
922            None => continue,
923        };
924        let pid = spec.id.clone();
925        let wants = |t: &str, op: &str| {
926            spec.exts.iter().any(|(ty, _)| ty == t) || spec.ops.iter().any(|o| o == op)
927        };
928        if wants("candidate-provider", "ranking.candidates") {
929            let plug = Arc::clone(&plug);
930            let pid2 = pid.clone();
931            hooks.candidates.push(Box::new(move |goal: &str| {
932                let input = serde_json::json!({"goal": goal});
933                match scc_plugin_host::call(&plug, "ranking.candidates", input, None) {
934                    Ok(v) => v.get("candidates").and_then(|s| s.as_array()).map(|a| {
935                        a.iter().filter_map(|e| {
936                            let id = e.get("id").and_then(|x| x.as_str()).unwrap_or("").to_string();
937                            if id.is_empty() { return None; }
938                            Some(scc_context::rank::ScoredEntity {
939                                id,
940                                kind: e.get("kind").and_then(|x| x.as_str()).unwrap_or("symbol").into(),
941                                name: e.get("name").and_then(|x| x.as_str()).unwrap_or("").into(),
942                                score: e.get("score").and_then(|x| x.as_f64()).unwrap_or(0.0),
943                                reason: format!("plugin:{}", pid2),
944                            })
945                        }).collect()
946                    }).unwrap_or_default(),
947                    Err(_) => Vec::new(),
948                }
949            }));
950        }
951        if wants("seed-provider", "ranking.seed") {
952            let plug = Arc::clone(&plug);
953            let g = goal.to_string();
954            hooks.seed_providers.push(Box::new(move |_goal| {
955                let input = serde_json::json!({"goal": g});
956                match scc_plugin_host::call(&plug, "ranking.seed", input, None) {
957                    Ok(v) => v.get("seeds").and_then(|s| s.as_array()).map(|a| {
958                        a.iter().map(|e| scc_core::TaskSeed {
959                            kind: e.get("kind").and_then(|x| x.as_str()).unwrap_or("symbol").into(),
960                            id: e.get("id").and_then(|x| x.as_str()).unwrap_or("").into(),
961                            weight: e.get("weight").and_then(|x| x.as_f64()).unwrap_or(0.0),
962                        }).filter(|s| !s.id.is_empty()).collect()
963                    }).unwrap_or_default(),
964                    Err(_) => Vec::new(),
965                }
966            }));
967        }
968        if wants("rank-feature", "ranking.feature") {
969            let plug = Arc::clone(&plug);
970            hooks.features.push(Box::new(move |sym, goal| {
971                let input = serde_json::json!({"symbol": sym, "goal": goal});
972                match scc_plugin_host::call(&plug, "ranking.feature", input, None) {
973                    Ok(v) => crate::ranking::RankFeatureValue {
974                        name: format!("{pid}.feature"),
975                        score: v.get("score").and_then(|x| x.as_f64()).unwrap_or(0.0),
976                        weight: v.get("weight").and_then(|x| x.as_f64()).unwrap_or(0.0),
977                        reason: v.get("reason").and_then(|x| x.as_str()).unwrap_or("").into(),
978                    },
979                    Err(_) => crate::ranking::RankFeatureValue { name: format!("{pid}.feature"), score: 0.0, weight: 0.0, reason: String::new() },
980                }
981            }));
982        }
983        if wants("rank-node", "ranking.rank_nodes") {
984            // §124 item 17: rank-universe nodes without canonical facts.
985            // One call per request (input: goal); the plugin answers
986            // `{"nodes": [{id, kind}]}`. Empty ids, unknown kinds, and
987            // duplicates of view nodes degrade inside the ranker.
988            let plug = Arc::clone(&plug);
989            let g = goal.to_string();
990            hooks.rank_nodes.push(Box::new(move |_goal| {
991                let input = serde_json::json!({"goal": g});
992                match scc_plugin_host::call(&plug, "ranking.rank_nodes", input, None) {
993                    Ok(v) => v.get("nodes").and_then(|s| s.as_array()).map(|a| {
994                        a.iter().filter_map(|e| {
995                            let id = e.get("id").and_then(|x| x.as_str()).unwrap_or("").to_string();
996                            let kind = e.get("kind").and_then(|x| x.as_str()).unwrap_or("").to_string();
997                            if id.is_empty() || kind.is_empty() { return None; }
998                            Some((id, kind))
999                        }).collect()
1000                    }).unwrap_or_default(),
1001                    Err(_) => Vec::new(),
1002                }
1003            }));
1004        }
1005        if wants("rank-edge", "ranking.rank_edges") {
1006            // §48: rank-time edges without canonical facts. One call per
1007            // request (input: goal); the plugin answers
1008            // `{"edges": [{subject, predicate, object, weight}]}`. Unknown
1009            // ids and bad weights degrade inside the ranker, never fail.
1010            let plug = Arc::clone(&plug);
1011            let g = goal.to_string();
1012            hooks.rank_edges.push(Box::new(move |_goal| {
1013                let input = serde_json::json!({"goal": g});
1014                match scc_plugin_host::call(&plug, "ranking.rank_edges", input, None) {
1015                    Ok(v) => v.get("edges").and_then(|s| s.as_array()).map(|a| {
1016                        a.iter().filter_map(|e| {
1017                            let (sub, pred, obj) = (
1018                                e.get("subject").and_then(|x| x.as_str()).unwrap_or("").to_string(),
1019                                e.get("predicate").and_then(|x| x.as_str()).unwrap_or("calls").to_string(),
1020                                e.get("object").and_then(|x| x.as_str()).unwrap_or("").to_string(),
1021                            );
1022                            let w = e.get("weight").and_then(|x| x.as_f64()).unwrap_or(0.0);
1023                            if sub.is_empty() || obj.is_empty() { return None; }
1024                            Some((sub, pred, obj, w))
1025                        }).collect()
1026                    }).unwrap_or_default(),
1027                    Err(_) => Vec::new(),
1028                }
1029            }));
1030        }
1031        if wants("criticality-provider", "ranking.criticality") {
1032            // §53: per-symbol criticality override in [0,1]; None abstains.
1033            // One call per (symbol, goal) pair at blend time; failures and
1034            // out-of-range values abstain (resolve_override degrades).
1035            let plug = Arc::clone(&plug);
1036            hooks.criticality.push(Box::new(move |sym: &str, goal: &str| {
1037                let input = serde_json::json!({"symbol": sym, "goal": goal});
1038                match scc_plugin_host::call(&plug, "ranking.criticality", input, None) {
1039                    Ok(v) => v.get("criticality").and_then(|x| x.as_f64()),
1040                    Err(_) => None,
1041                }
1042            }));
1043        }
1044        if wants("novelty-provider", "ranking.novelty") {
1045            // §53: per-symbol novelty override in [0,1]; None abstains.
1046            let plug = Arc::clone(&plug);
1047            hooks.novelty.push(Box::new(move |sym: &str, goal: &str| {
1048                let input = serde_json::json!({"symbol": sym, "goal": goal});
1049                match scc_plugin_host::call(&plug, "ranking.novelty", input, None) {
1050                    Ok(v) => v.get("novelty").and_then(|x| x.as_f64()),
1051                    Err(_) => None,
1052                }
1053            }));
1054        }
1055        if wants("risk-provider", "ranking.risk") {
1056            // §53: per-symbol change-risk override in [0,1]; None abstains.
1057            let plug = Arc::clone(&plug);
1058            hooks.risk.push(Box::new(move |sym: &str, goal: &str| {
1059                let input = serde_json::json!({"symbol": sym, "goal": goal});
1060                match scc_plugin_host::call(&plug, "ranking.risk", input, None) {
1061                    Ok(v) => v.get("risk").and_then(|x| x.as_f64()),
1062                    Err(_) => None,
1063                }
1064            }));
1065        }
1066        if wants("semantic-provider", "ranking.semantic") {
1067            // §53: per-symbol semantic relevance in [0,1]; None abstains.
1068            let plug = Arc::clone(&plug);
1069            hooks.semantic.push(Box::new(move |sym: &str, goal: &str| {
1070                let input = serde_json::json!({"symbol": sym, "goal": goal});
1071                match scc_plugin_host::call(&plug, "ranking.semantic", input, None) {
1072                    Ok(v) => v.get("semantic").and_then(|x| x.as_f64()),
1073                    Err(_) => None,
1074                }
1075            }));
1076        }
1077        if wants("edge-weight", "ranking.edge_weight") {
1078            let plug = Arc::clone(&plug);
1079            hooks.edge_weights.push(std::sync::Arc::new(move |subject, predicate, object, base| {
1080                let input = serde_json::json!({
1081                    "subject": subject, "predicate": predicate,
1082                    "object": object, "base": base,
1083                });
1084                match scc_plugin_host::call(&plug, "ranking.edge_weight", input, None) {
1085                    Ok(v) => {
1086                        let mode = v.get("mode").and_then(|x| x.as_str()).unwrap_or("").to_string();
1087                        let value = v.get("value").and_then(|x| x.as_f64()).unwrap_or(0.0);
1088                        match mode.as_str() {
1089                            "add" | "multiply" | "replace" | "veto" => Some((mode, value)),
1090                            _ => None,
1091                        }
1092                    }
1093                    Err(_) => None,
1094                }
1095            }));
1096        }
1097        // Named blend profiles (spec 12 + DoD 25): a
1098        // `blend-profile:<name>` extension registers profile `<name>` via
1099        // the plugin's `ranking.profile` op returning weight overrides.
1100        for eid in spec.exts.iter().filter(|(ty, _)| ty == "blend-profile").map(|(_, id)| id) {
1101            let name = eid.strip_prefix("blend-profile:").unwrap_or(eid).to_string();
1102            let plug = Arc::clone(&plug);
1103            if let Ok(v) = scc_plugin_host::call(&plug, "ranking.profile", serde_json::json!({"profile": name}), None) {
1104                let w = v.get("weights").cloned().unwrap_or(serde_json::Value::Null);
1105                let mut bw = crate::ranking::BlendWeights::default();
1106                let mut bad: Vec<String> = Vec::new();
1107                if let Some(obj) = w.as_object() {
1108                    for (k, val) in obj {
1109                        let num = val.as_f64();
1110                        match (k.as_str(), num) {
1111                            ("task_ppr", Some(x)) => bw.task_ppr = Some(x),
1112                            ("global_ppr", Some(x)) => bw.global_ppr = Some(x),
1113                            ("lexical", Some(x)) => bw.lexical = Some(x),
1114                            ("semantic", Some(x)) => bw.semantic = Some(x),
1115                            ("confidence", Some(x)) => bw.confidence = Some(x),
1116                            ("criticality", Some(x)) => bw.criticality = Some(x),
1117                            ("change_risk", Some(x)) => bw.change_risk = Some(x),
1118                            ("novelty", Some(x)) => bw.novelty = Some(x),
1119                            _ => bad.push(k.clone()),
1120                        }
1121                    }
1122                }
1123                // Unknown feature keys fail loudly at registration, not
1124                // silently ignored (would lie about the blend).
1125                if !bad.is_empty() {
1126                    ap.diagnostics.push(scc_plugin_host::PluginDiagnostic {
1127                        plugin: spec.id.clone(), operation: "ranking.profile".into(),
1128                        error: format!("unknown blend features for profile '{name}': {}", bad.join(", ")),
1129                        action: "skipped".into(),
1130                    });
1131                    continue;
1132                }
1133                hooks.profiles.insert(name, bw);
1134            }
1135        }
1136        if wants("coverage", "ranking.coverage") {
1137            let plug = Arc::clone(&plug);
1138            hooks.coverage.push(Box::new(move |goal: &str| {
1139                let input = serde_json::json!({"goal": goal});
1140                match scc_plugin_host::call(&plug, "ranking.coverage", input, None) {
1141                    Ok(v) => v.get("required").and_then(|a| a.as_array()).map(|a| {
1142                        a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()
1143                    }).unwrap_or_default(),
1144                    Err(_) => Vec::new(),
1145                }
1146            }));
1147        }
1148        if wants("similarity", "ranking.similarity") {
1149            let plug = Arc::clone(&plug);
1150            hooks.similarities.push(std::sync::Arc::new(move |a, b, ga, gb| {
1151                let input = serde_json::json!({"a": a, "b": b, "group_a": ga, "group_b": gb});
1152                match scc_plugin_host::call(&plug, "ranking.similarity", input, None) {
1153                    Ok(v) => v.get("similarity").and_then(|x| x.as_f64()).unwrap_or(0.0),
1154                    Err(_) => 0.0,
1155                }
1156            }));
1157        }
1158        if wants("reranker", "ranking.rerank") {
1159            let plug = Arc::clone(&plug);
1160            hooks.rerankers.push(Box::new(move |items, goal| {
1161                let input = serde_json::json!({"goal": goal, "items": items.iter().map(|i: &scc_api::RankItem| i.id.clone()).collect::<Vec<_>>()});
1162                if let Ok(v) = scc_plugin_host::call(&plug, "ranking.rerank", input, None) {
1163                    if let Some(order) = v.get("order").and_then(|o| o.as_array()) {
1164                        let pos: std::collections::HashMap<&str, usize> =
1165                            order.iter().enumerate().filter_map(|(i, x)| x.as_str().map(|s| (s, i))).collect();
1166                        items.sort_by_key(|i| (pos.get(i.id.as_str()).copied().unwrap_or(usize::MAX), i.id.clone()));
1167                        for (k, it) in items.iter_mut().enumerate() { it.position = k + 1; }
1168                    }
1169                }
1170            }));
1171        }
1172    }
1173    hooks
1174}
1175
1176// trace:exempt reason=internal-detail
1177fn invoke_context(
1178    ctx: &crate::SccContext,
1179    store: &scc_store::Store,
1180    config: &scc_indexer::Config,
1181    operation: &str,
1182    input: Value,
1183) -> crate::Result<Value> {
1184    Ok(match operation {
1185        "context.component" => {
1186            let req: scc_api::DetailRequest = serde_json::from_value(input)?;
1187            serde_json::to_value(ctx.component(&req)?)?
1188        }
1189        "context.flow" => {
1190            let req: scc_api::DetailRequest = serde_json::from_value(input)?;
1191            serde_json::to_value(ctx.flow(&req)?)?
1192        }
1193        "context.impact" => {
1194            let req: scc_api::ImpactRequest = serde_json::from_value(input)?;
1195            serde_json::to_value(ctx.impact(&req)?)?
1196        }
1197        "context.verify" => {
1198            let unbounded = input.get("unbounded").and_then(|v| v.as_bool()).unwrap_or(false);
1199            let mut pack = ctx.verify(unbounded)?;
1200            // Plugin verify diagnostics (§124 item 30): appended by the
1201            // engine so every transport delivers them. Notes inline (the
1202            // verify pack surfaces warnings as content).
1203            let (sections, notes) = crate::plugins::verify_diagnostics(&store.root, config);
1204            pack.content.push_str(&sections);
1205            for n in notes {
1206                pack.content.push_str(&format!("\n(verify diagnostic skipped: {n})\n"));
1207            }
1208            serde_json::to_value(pack)?
1209        }
1210        "context.structural" | "source.structural" => {
1211            let req: scc_api::StructuralRequest = serde_json::from_value(input)?;
1212            let goal = req.task.clone().unwrap_or_default();
1213            let (scorer, _) = crate::inference::rankers(store, config, &goal);
1214            let semantic: Option<&dyn scc_context::rank::SemanticScorer> =
1215                scorer.as_ref().map(|s| s as &dyn scc_context::rank::SemanticScorer);
1216            // §73: units model first — external programs use the
1217            // structured units without scraping text. Text comes from
1218            // ctx.structural (owns the HANDLE REFUSED / empty envelopes);
1219            // units come from structural_units (model data only).
1220            let text = ctx.structural(&req, &store.root, semantic)?;
1221            let units = ctx.structural_units(&req, &store.root, semantic)?;
1222            serde_json::json!({"text": text, "units": units})
1223        }
1224        "surface.compile" => {
1225            serde_json::to_value(ctx.surface_map()?)?
1226        }
1227        "surface.render" => {
1228            let req: scc_api::SurfaceRequest = serde_json::from_value(input)?;
1229            let goal = req.task.clone().unwrap_or_default();
1230            let (scorer, _) = crate::inference::rankers(store, config, &goal);
1231            let semantic: Option<&dyn scc_context::rank::SemanticScorer> =
1232                scorer.as_ref().map(|s| s as &dyn scc_context::rank::SemanticScorer);
1233            let (result, _) = ctx.surface(&req, semantic)?;
1234            serde_json::json!({ "text": result.text })
1235        }
1236        "surface.build" | "surface.global" | "surface.task" | "ranking.important" | "surface.important" => {
1237            let req: scc_api::SurfaceRequest = serde_json::from_value(input)?;
1238            let goal = req.task.clone().unwrap_or_default();
1239            let (scorer, _) = crate::inference::rankers(store, config, &goal);
1240            let semantic: Option<&dyn scc_context::rank::SemanticScorer> =
1241                scorer.as_ref().map(|s| s as &dyn scc_context::rank::SemanticScorer);
1242            let (result, text) = ctx.surface(&req, semantic)?;
1243            serde_json::json!({ "result": result, "text": text })
1244        }
1245        _ => unreachable!("invoke routes only context ops here"),
1246    })
1247}
1248
1249// trace:exempt reason=internal-detail
1250fn status_value(store: &scc_store::Store) -> crate::Result<Value> {
1251    let repo = store.repository();
1252    let stale = crate::workspace::stale_paths(store)?;
1253    let (revision, indexed_at, branch) = match store.snapshot_status()? {
1254        Some((snap, _)) => (snap.revision, Some(snap.indexed_at), snap.branch),
1255        None => ("not-indexed".to_string(), None, None),
1256    };
1257    Ok(serde_json::json!({
1258        "repository": repo.name,
1259        "repository_id": repo.id,
1260        "remote": repo.url,
1261        "revision": revision,
1262        "branch": branch,
1263        "indexed_at": indexed_at,
1264        "stats": store.stats()?,
1265        "freshness": if stale.is_empty() { "CURRENT" } else { "STALE" },
1266        "stale_files": stale.iter().take(10).collect::<Vec<_>>(),
1267        "stale_count": stale.len(),
1268    }))
1269}
1270
1271// trace:exempt reason=internal-detail
1272// trace:exempt reason=internal-detail
1273// trace:exempt reason=internal-detail
1274// trace:v1 id=impl.scc-engine-invoke.exporter-plugins work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1275fn plugin_export(
1276    root: &std::path::Path,
1277    config: &scc_indexer::Config,
1278    format: &str,
1279) -> Option<Value> {
1280    // `exporter:<format>` extensions render a format the engine does not
1281    // know (e.g. SARIF, GraphML). Input carries the requested format;
1282    // output is verbatim `{"format-available": true, "text"|"json"}`.
1283    let ap = crate::plugins::active(root, config);
1284    let specs: Vec<(String, String, String)> = ap
1285        .plugins
1286        .iter()
1287        .flat_map(|pl| {
1288            pl.manifest.extensions.iter()
1289                .filter(|e| e.extension_type == "exporter" && e.id.strip_prefix("exporter:").unwrap_or(&e.id) == format)
1290                .map(|e| (pl.manifest.id.clone(), e.id.clone(), pl.manifest.failure_policy.clone()))
1291        })
1292        .collect();
1293    for (pid, ext_id, policy) in specs {
1294        let plug = match ap.plugins.iter().find(|pl| pl.manifest.id == pid).cloned() {
1295            Some(pl) => pl,
1296            None => continue,
1297        };
1298        match scc_plugin_host::call(&plug, "export.render", serde_json::json!({"format": format}), None) {
1299            Ok(v) => {
1300                let has = v.get("format-available").and_then(|x| x.as_bool()).unwrap_or(false);
1301                let body = v.get("text").or_else(|| v.get("json"));
1302                if has && body.is_some() {
1303                    // Provenance tags the output so consumers know a plugin
1304                    // rendered it — never a native export masquerading.
1305                    let mut out = serde_json::json!({"format": format, "plugin": pid, "extension": ext_id});
1306                    out["output"] = body.cloned().unwrap();
1307                    return Some(out);
1308                }
1309            }
1310            Err(e) => {
1311                if policy == "required" {
1312                    return Some(serde_json::json!({"error": format!("exporter {ext_id} from {pid} FAILED: {e}")}));
1313                }
1314            }
1315        }
1316    }
1317    None
1318}
1319
1320// trace:exempt reason=internal-detail
1321fn export_value(store: &scc_store::Store, root: &std::path::Path, format: &str) -> crate::Result<Value> {
1322    // Plugin exporters (§124 item 31) fire first for unknown formats; the
1323    // engine falls back to built-ins. `config` loads here (not threaded
1324    // through every caller) so the seam stays one line at each site.
1325    if let Ok(config) = crate::workspace::load_config(root) {
1326        if let Some(v) = plugin_export(root, &config, format) {
1327            if v.get("error").is_some() {
1328                let msg = v["error"].as_str().unwrap_or("exporter failed");
1329                return Err(crate::EngineError::Other(msg.into()));
1330            }
1331            return Ok(v);
1332        }
1333    }
1334    let ir = crate::exports::system_ir(store)?;
1335    match format {
1336        "system-ir.json" | "system-ir.jsonl" | "ccg" | "flow-graphs.json" => {
1337            Ok(match format {
1338                "system-ir.jsonl" => Value::Array(crate::exports::jsonl(&ir)?.into_iter().map(Value::String).collect()),
1339                "ccg" => crate::exports::ccg(&ir)?,
1340                "flow-graphs.json" => serde_json::to_value(store.flow_graphs()?)?,
1341                _ => serde_json::to_value(&ir)?,
1342            })
1343        }
1344        "capsule.md" => Ok(Value::String(crate::exports::capsule(root)?)),
1345        other => Err(crate::EngineError::Other(format!(
1346            "unknown export format '{other}' (use system-ir.json, system-ir.jsonl, ccg, flow-graphs.json, capsule.md)"
1347        ))),
1348    }
1349}